diff --git a/locales/en-US/electron.json b/locales/en-US/electron.json index cdc54f3dad..cd395b6cd0 100644 --- a/locales/en-US/electron.json +++ b/locales/en-US/electron.json @@ -35,6 +35,7 @@ "navigation.recentView": "Recent pages", "navigation.resources": "Resources", "navigation.settings": "Settings", + "navigation.task": "Task", "navigation.tasks": "Tasks", "navigation.unpin": "Unpin", "notification.finishChatGeneration": "AI message generation completed", diff --git a/locales/zh-CN/electron.json b/locales/zh-CN/electron.json index c326d4df69..7d856312f9 100644 --- a/locales/zh-CN/electron.json +++ b/locales/zh-CN/electron.json @@ -35,6 +35,7 @@ "navigation.recentView": "最近访问", "navigation.resources": "资源", "navigation.settings": "设置", + "navigation.task": "任务", "navigation.tasks": "任务", "navigation.unpin": "取消固定", "notification.finishChatGeneration": "AI 消息已生成完毕", diff --git a/src/components/PageTitle/index.tsx b/src/components/PageTitle/index.tsx deleted file mode 100644 index 850a060b92..0000000000 --- a/src/components/PageTitle/index.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { BRANDING_NAME } from '@lobechat/business-const'; -import { memo, useEffect } from 'react'; - -import { isDesktop } from '@/const/version'; -import { useElectronStore } from '@/store/electron'; - -const PageTitle = memo<{ title: string }>(({ title }) => { - const setCurrentPageTitle = useElectronStore((s) => s.setCurrentPageTitle); - - useEffect(() => { - document.title = title ? `${title} · ${BRANDING_NAME}` : BRANDING_NAME; - - // Sync title to electron store for navigation history - if (isDesktop) { - setCurrentPageTitle(title); - } - }, [title, setCurrentPageTitle]); - - return null; -}); - -export default PageTitle; diff --git a/src/features/AgentTasks/routeMeta.ts b/src/features/AgentTasks/routeMeta.ts new file mode 100644 index 0000000000..92d52977e2 --- /dev/null +++ b/src/features/AgentTasks/routeMeta.ts @@ -0,0 +1,22 @@ +import { ListTodoIcon } from 'lucide-react'; + +import { type DynamicRouteMeta, routeMeta } from '@/spa/router/routeMeta'; +import { useTaskStore } from '@/store/task'; +import { taskDetailSelectors } from '@/store/task/selectors'; + +export const tasksRouteMeta = routeMeta({ + icon: ListTodoIcon, + titleKey: 'navigation.tasks', +}); + +export const taskRouteMeta = routeMeta({ + icon: ListTodoIcon, + titleKey: 'navigation.task', + useDynamicMeta: (params): DynamicRouteMeta => { + const detail = useTaskStore(taskDetailSelectors.taskDetailById(params.taskId ?? '')); + + return { + title: detail?.name || undefined, + }; + }, +}); diff --git a/src/features/Electron/navigation/cachedData.ts b/src/features/Electron/navigation/cachedData.ts deleted file mode 100644 index 823d414deb..0000000000 --- a/src/features/Electron/navigation/cachedData.ts +++ /dev/null @@ -1,89 +0,0 @@ -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 { 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'; - -/** - * Get cached display data for a page reference - * Shared by useNavigationHistory and useTabNavigation - */ -export const getCachedDataForReference = (reference: PageReference): CachedPageData | undefined => { - switch (reference.type) { - case 'agent': - case 'agent-topic': - case 'agent-topic-page': { - 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; - - let title = meta.title; - if ( - (reference.type === 'agent-topic' || reference.type === 'agent-topic-page') && - '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; - - 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: { - return undefined; - } - } -}; diff --git a/src/features/Electron/navigation/routeMetadata.ts b/src/features/Electron/navigation/routeMetadata.ts deleted file mode 100644 index e1d0129690..0000000000 --- a/src/features/Electron/navigation/routeMetadata.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * Route metadata mapping for navigation history - * Provides title and icon information based on route path - */ -import { type LucideIcon } from 'lucide-react'; -import { Circle, Home, MessageSquare, Rocket, ShapesIcon, Users } from 'lucide-react'; - -import { getRouteById } from '@/config/routes'; - -export interface RouteMetadata { - icon?: LucideIcon; - /** i18n key for the title (namespace: electron) */ - titleKey: string; - /** Whether this route should use document.title for more specific title */ - useDynamicTitle?: boolean; -} - -interface RoutePattern { - icon?: LucideIcon; - test: (pathname: string) => boolean; - /** i18n key for the title (namespace: electron) */ - titleKey: string; - /** Whether this route should use document.title for more specific title */ - useDynamicTitle?: boolean; -} - -// Get shared icons -const communityIcon = getRouteById('community')?.icon; -const resourceIcon = getRouteById('resource')?.icon; -const memoryIcon = getRouteById('memory')?.icon; -const imageIcon = getRouteById('image')?.icon; -const pageIcon = getRouteById('page')?.icon; -const settingsIcon = getRouteById('settings')?.icon; -const tasksIcon = getRouteById('tasks')?.icon; - -/** - * Route patterns ordered by specificity (most specific first) - */ -const routePatterns: RoutePattern[] = [ - // Settings routes - { - icon: settingsIcon, - test: (p) => p.startsWith('/settings/provider'), - titleKey: 'navigation.provider', - }, - { - icon: settingsIcon, - test: (p) => p.startsWith('/settings'), - titleKey: 'navigation.settings', - }, - - // Agent/Chat routes - use dynamic title for specific chat names - { - icon: pageIcon, - test: (p) => /^\/agent\/[^/]+\/tpc_[^/]+\/page$/.test(p), - titleKey: 'navigation.page', - useDynamicTitle: true, - }, - { - icon: MessageSquare, - test: (p) => p.startsWith('/agent/') && !/^\/agent\/[^/]+\/task\//.test(p), - titleKey: 'navigation.chat', - useDynamicTitle: true, - }, - { - icon: MessageSquare, - test: (p) => p === '/agent', - titleKey: 'navigation.chat', - }, - - // Group routes - use dynamic title for specific group names - { - icon: Users, - test: (p) => p.startsWith('/group/'), - titleKey: 'navigation.groupChat', - useDynamicTitle: true, - }, - { - icon: Users, - test: (p) => p === '/group', - titleKey: 'navigation.group', - }, - - // Community/Discover routes - { - icon: ShapesIcon, - test: (p) => p.startsWith('/community/agent'), - titleKey: 'navigation.discoverAssistants', - }, - { - icon: communityIcon, - test: (p) => p.startsWith('/community/model'), - titleKey: 'navigation.discoverModels', - }, - { - icon: communityIcon, - test: (p) => p.startsWith('/community/provider'), - titleKey: 'navigation.discoverProviders', - }, - { - icon: communityIcon, - test: (p) => p.startsWith('/community/mcp'), - titleKey: 'navigation.discoverMcp', - }, - { - icon: communityIcon, - test: (p) => p.startsWith('/community'), - titleKey: 'navigation.discover', - }, - - // Resource/Knowledge routes - { - icon: resourceIcon, - test: (p) => p.startsWith('/resource/library'), - titleKey: 'navigation.knowledgeBase', - }, - { - icon: resourceIcon, - test: (p) => p.startsWith('/resource'), - titleKey: 'navigation.resources', - }, - - // Tasks routes (cross-agent global view + singular task detail) - { - icon: tasksIcon, - test: (p) => - p.startsWith('/tasks') || p.startsWith('/task/') || /^\/agent\/[^/]+\/task\//.test(p), - titleKey: 'navigation.tasks', - }, - - // Memory routes - { - icon: memoryIcon, - test: (p) => p.startsWith('/memory/identities'), - titleKey: 'navigation.memoryIdentities', - }, - { - icon: memoryIcon, - test: (p) => p.startsWith('/memory/contexts'), - titleKey: 'navigation.memoryContexts', - }, - { - icon: memoryIcon, - test: (p) => p.startsWith('/memory/preferences'), - titleKey: 'navigation.memoryPreferences', - }, - { - icon: memoryIcon, - test: (p) => p.startsWith('/memory/experiences'), - titleKey: 'navigation.memoryExperiences', - }, - { - icon: memoryIcon, - test: (p) => p.startsWith('/memory'), - titleKey: 'navigation.memory', - }, - - // Image routes - { - icon: imageIcon, - test: (p) => p.startsWith('/image'), - titleKey: 'navigation.image', - }, - - // Page routes - use dynamic title for specific page names - { - icon: pageIcon, - test: (p) => p.startsWith('/page/'), - titleKey: 'navigation.page', - useDynamicTitle: true, - }, - { - icon: pageIcon, - test: (p) => p === '/page', - titleKey: 'navigation.pages', - }, - - // Onboarding - { - icon: Rocket, - test: (p) => p.startsWith('/desktop-onboarding') || p.startsWith('/onboarding'), - titleKey: 'navigation.onboarding', - }, - - // Home (default) - { - icon: Home, - test: (p) => p === '/' || p === '', - titleKey: 'navigation.home', - }, -]; - -/** - * Get route metadata based on pathname - * @param pathname - The current route pathname - * @returns Route metadata with titleKey, icon, and useDynamicTitle flag - */ -export const getRouteMetadata = (pathname: string): RouteMetadata => { - // Find the first matching pattern - for (const pattern of routePatterns) { - if (pattern.test(pathname)) { - return { - icon: pattern.icon, - titleKey: pattern.titleKey, - useDynamicTitle: pattern.useDynamicTitle, - }; - } - } - - // Default fallback - return { - icon: Circle, - titleKey: 'navigation.lobehub', - }; -}; - -/** - * Get route icon based on pathname or URL - * @param url - The route URL (may include query string) - * @returns LucideIcon component or undefined - */ -export const getRouteIcon = (url: string): LucideIcon | undefined => { - // Extract pathname from URL - const pathname = url.split('?')[0]; - const metadata = getRouteMetadata(pathname); - return metadata.icon; -}; diff --git a/src/features/Electron/navigation/useNavigationHistory.ts b/src/features/Electron/navigation/useNavigationHistory.ts index b9fc14970c..b28cae2650 100644 --- a/src/features/Electron/navigation/useNavigationHistory.ts +++ b/src/features/Electron/navigation/useNavigationHistory.ts @@ -5,26 +5,21 @@ 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 { matchRouteMeta } from '@/features/Electron/titlebar/TabBar/resolveRouteMeta'; +import { normalizeTabUrl } from '@/features/Electron/titlebar/TabBar/url'; +import { desktopRoutes } from '@/spa/router/desktopRouter.config'; import { useElectronStore } from '@/store/electron'; -import { getCachedDataForReference } from './cachedData'; -import { getRouteMetadata } from './routeMetadata'; - -/** - * Hook to manage navigation history in Electron desktop app - * Provides browser-like back/forward functionality - */ export const useNavigationHistory = () => { const { t } = useTranslation('electron'); const navigate = useNavigate(); const location = useLocation(); - // Get store state and actions const isNavigatingHistory = useElectronStore((s) => s.isNavigatingHistory); const historyCurrentIndex = useElectronStore((s) => s.historyCurrentIndex); const historyEntries = useElectronStore((s) => s.historyEntries); - const currentPageTitle = useElectronStore((s) => s.currentPageTitle); + const currentRouteMeta = useElectronStore((s) => s.currentRouteMeta); + const currentRouteMetaUrl = useElectronStore((s) => s.currentRouteMetaUrl); const pushHistory = useElectronStore((s) => s.pushHistory); const replaceHistory = useElectronStore((s) => s.replaceHistory); const setIsNavigatingHistory = useElectronStore((s) => s.setIsNavigatingHistory); @@ -35,10 +30,8 @@ export const useNavigationHistory = () => { const getCurrentEntry = useElectronStore((s) => s.getCurrentEntry); const addRecentPage = useElectronStore((s) => s.addRecentPage); - // Track previous location to avoid duplicate entries const prevLocationRef = useRef(null); - // Calculate can go back/forward const canGoBack = historyCurrentIndex > 0; const canGoForward = historyCurrentIndex < historyEntries.length - 1; @@ -46,66 +39,45 @@ export const useNavigationHistory = () => { if (!canGoBackFn()) return; const targetEntry = storeGoBack(); - if (targetEntry) { - navigate(targetEntry.url); - } + if (targetEntry) navigate(targetEntry.url); }, [canGoBackFn, storeGoBack, navigate]); const goForward = useCallback(() => { if (!canGoForwardFn()) return; const targetEntry = storeGoForward(); - if (targetEntry) { - navigate(targetEntry.url); - } + if (targetEntry) navigate(targetEntry.url); }, [canGoForwardFn, storeGoForward, navigate]); - // Listen to route changes and push history useEffect(() => { const currentUrl = location.pathname + location.search; - // Skip if this is a back/forward navigation if (isNavigatingHistory) { setIsNavigatingHistory(false); prevLocationRef.current = currentUrl; return; } - // Skip if same as previous location - if (prevLocationRef.current === currentUrl) { - return; - } + if (prevLocationRef.current === currentUrl) return; - // Skip if same as current entry const currentEntry = getCurrentEntry(); if (currentEntry?.url === currentUrl) { prevLocationRef.current = currentUrl; return; } - // Get metadata for this route - const metadata = getRouteMetadata(location.pathname); - const presetTitle = t(metadata.titleKey as any) as string; + const staticMeta = matchRouteMeta(desktopRoutes, currentUrl).static; + const presetTitle = staticMeta.titleKey + ? (t(staticMeta.titleKey as never) as string) + : (t('navigation.lobehub') as string); - // Push history with preset title (will be updated by PageTitle if useDynamicTitle) pushHistory({ - metadata: { - timestamp: Date.now(), - }, + metadata: { timestamp: Date.now() }, title: presetTitle, 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); - } - } + addRecentPage(currentUrl); prevLocationRef.current = currentUrl; }, [ @@ -119,48 +91,19 @@ export const useNavigationHistory = () => { t, ]); - // Update current history entry title when PageTitle component updates useEffect(() => { - if (!currentPageTitle) return; + const dynamicTitle = currentRouteMeta?.title; + if (!dynamicTitle || !currentRouteMetaUrl) return; const currentEntry = getCurrentEntry(); if (!currentEntry) return; + if (normalizeTabUrl(currentEntry.url) !== normalizeTabUrl(currentRouteMetaUrl)) return; + if (currentEntry.title === dynamicTitle) return; - // Check if current route supports dynamic title - const metadata = getRouteMetadata(location.pathname); - if (!metadata.useDynamicTitle) return; + replaceHistory({ ...currentEntry, title: dynamicTitle }); + addRecentPage(currentEntry.url, currentRouteMeta ?? undefined); + }, [currentRouteMeta, currentRouteMetaUrl, getCurrentEntry, replaceHistory, addRecentPage]); - // Skip if title is already the same - if (currentEntry.title === currentPageTitle) return; - - // Update the current history entry with the dynamic title - replaceHistory({ - ...currentEntry, - title: currentPageTitle, - }); - - // 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', () => { goBack(); }); diff --git a/src/features/Electron/navigation/useTabNavigation.ts b/src/features/Electron/navigation/useTabNavigation.ts index 1ee7876f92..08b4197f11 100644 --- a/src/features/Electron/navigation/useTabNavigation.ts +++ b/src/features/Electron/navigation/useTabNavigation.ts @@ -3,17 +3,9 @@ import { useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; -import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins'; +import { normalizeTabUrl } from '@/features/Electron/titlebar/TabBar/url'; import { useElectronStore } from '@/store/electron'; -import { getCachedDataForReference } from './cachedData'; - -/** - * Hook to sync route changes with tab state - * - Does NOT auto-create tabs (tabs are created explicitly via context menu / double-click) - * - When navigating within an active tab, updates that tab's reference to track current location - * - Updates tab cache when dynamic title changes - */ export const useTabNavigation = () => { const location = useLocation(); @@ -21,12 +13,12 @@ export const useTabNavigation = () => { const updateTab = useElectronStore((s) => s.updateTab); const updateTabCache = useElectronStore((s) => s.updateTabCache); const loadTabs = useElectronStore((s) => s.loadTabs); - const currentPageTitle = useElectronStore((s) => s.currentPageTitle); + const currentRouteMeta = useElectronStore((s) => s.currentRouteMeta); + const currentRouteMetaUrl = useElectronStore((s) => s.currentRouteMetaUrl); const prevLocationRef = useRef(null); const loadedRef = useRef(false); - // Load tabs from localStorage on mount useEffect(() => { if (!loadedRef.current) { loadTabs(); @@ -34,46 +26,31 @@ export const useTabNavigation = () => { } }, [loadTabs]); - // Sync route changes to tabs (no auto-creation) useEffect(() => { const currentUrl = location.pathname + location.search; if (prevLocationRef.current === currentUrl) return; prevLocationRef.current = currentUrl; - const reference = pluginRegistry.parseUrl(location.pathname, location.search); - if (!reference) return; - + const id = normalizeTabUrl(currentUrl); const { tabs, activeTabId } = useElectronStore.getState(); - // If this exact page is already a tab, activate it - const existing = tabs.find((t) => t.id === reference.id); + const existing = tabs.find((t) => t.id === id); if (existing) { - if (existing.id !== activeTabId) { - activateTab(existing.id); - } + if (existing.id !== activeTabId) activateTab(existing.id); return; } - // If there's an active tab, update it to track the new location - if (activeTabId) { - const cached = getCachedDataForReference(reference); - updateTab(activeTabId, reference, cached); - } + if (activeTabId) updateTab(activeTabId, currentUrl); }, [location.pathname, location.search, activateTab, updateTab]); - // Update tab cache when dynamic title changes useEffect(() => { - if (!currentPageTitle) return; + if (!currentRouteMeta || !currentRouteMetaUrl) return; - const { tabs, activeTabId } = useElectronStore.getState(); + const { activeTabId } = useElectronStore.getState(); if (!activeTabId) return; + if (activeTabId !== normalizeTabUrl(currentRouteMetaUrl)) return; - const tab = tabs.find((t) => t.id === activeTabId); - if (!tab) return; - - if (tab.cached?.title === currentPageTitle) return; - - updateTabCache(activeTabId, { title: currentPageTitle }); - }, [currentPageTitle, updateTabCache]); + updateTabCache(activeTabId, currentRouteMeta); + }, [currentRouteMeta, currentRouteMetaUrl, updateTabCache]); }; diff --git a/src/features/Electron/titlebar/NavigationBar.tsx b/src/features/Electron/titlebar/NavigationBar.tsx index 74b416a614..0cdee025ac 100644 --- a/src/features/Electron/titlebar/NavigationBar.tsx +++ b/src/features/Electron/titlebar/NavigationBar.tsx @@ -3,7 +3,7 @@ import { ActionIcon, Flexbox, Popover, Tooltip } from '@lobehub/ui'; import { createStaticStyles } from 'antd-style'; import { ArrowLeft, ArrowRight, Clock } from 'lucide-react'; -import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useGlobalStore } from '@/store/global'; @@ -14,7 +14,6 @@ import { isMacOS } from '@/utils/platform'; import { useNavigationHistory } from '../navigation/useNavigationHistory'; import RecentlyViewed from './RecentlyViewed'; -import { loadAllRecentlyViewedPlugins } from './RecentlyViewed/plugins'; const isMac = isMacOS(); @@ -37,17 +36,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ `, })); -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); diff --git a/src/features/Electron/titlebar/RecentlyViewed/PageItem.tsx b/src/features/Electron/titlebar/RecentlyViewed/PageItem.tsx index 099c6e6eb4..d59888396a 100644 --- a/src/features/Electron/titlebar/RecentlyViewed/PageItem.tsx +++ b/src/features/Electron/titlebar/RecentlyViewed/PageItem.tsx @@ -9,12 +9,13 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { useElectronStore } from '@/store/electron'; +import { type ResolvedTab } from '../TabBar/hooks/useResolvedTabs'; +import { normalizeTabUrl } from '../TabBar/url'; import { useStyles } from './styles'; -import { type ResolvedPageData } from './types'; interface PageItemProps { isPinned: boolean; - item: ResolvedPageData; + item: ResolvedTab; onClose: () => void; } @@ -27,21 +28,21 @@ const PageItem = memo(({ item, isPinned, onClose }) => { 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 { meta, tab } = item; + const currentId = normalizeTabUrl(location.pathname + location.search); + const isActive = tab.id === currentId; const handleClick = () => { - navigate(item.url); + navigate(tab.url); onClose(); }; const handlePinToggle = (e: React.MouseEvent) => { e.stopPropagation(); if (isPinned) { - unpinPage(item.reference.id); + unpinPage(tab.id); } else { - pinPage(item.reference); + pinPage(tab); } }; @@ -53,8 +54,8 @@ const PageItem = memo(({ item, isPinned, onClose }) => { gap={8} onClick={handleClick} > - {item.icon && } - {item.title} + {meta.icon && } + {meta.title} void; title: string; } @@ -22,7 +22,7 @@ const Section = memo(({ title, items, isPinned, onClose }) => { <>
{title}
{items.map((item) => ( - + ))} ); diff --git a/src/features/Electron/titlebar/RecentlyViewed/hooks/usePluginContext.ts b/src/features/Electron/titlebar/RecentlyViewed/hooks/usePluginContext.ts deleted file mode 100644 index b4f833cf02..0000000000 --- a/src/features/Electron/titlebar/RecentlyViewed/hooks/usePluginContext.ts +++ /dev/null @@ -1,72 +0,0 @@ -'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 any) 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 index 1954d6e8dd..297976d11a 100644 --- a/src/features/Electron/titlebar/RecentlyViewed/hooks/useResolvedPages.ts +++ b/src/features/Electron/titlebar/RecentlyViewed/hooks/useResolvedPages.ts @@ -1,34 +1,37 @@ 'use client'; import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { desktopRoutes } from '@/spa/router/desktopRouter.config'; import { useElectronStore } from '@/store/electron'; -import { pluginRegistry } from '../plugins'; -import { type ResolvedPageData } from '../types'; -import { usePluginContext } from './usePluginContext'; +import { type ResolvedTab, resolveTab } from '../../TabBar/hooks/useResolvedTabs'; interface UseResolvedPagesResult { - pinnedPages: ResolvedPageData[]; - recentPages: ResolvedPageData[]; + pinnedPages: ResolvedTab[]; + recentPages: ResolvedTab[]; } -/** - * Hook to resolve page references into display data - * Automatically filters out pages where data no longer exists - */ +type Translate = (key: string, options?: Record) => string; + export const useResolvedPages = (): UseResolvedPagesResult => { - const ctx = usePluginContext(); + const { t } = useTranslation('electron'); const pinnedRefs = useElectronStore((s) => s.pinnedPages); const recentRefs = useElectronStore((s) => s.recentPages); - const pinnedPages = useMemo(() => pluginRegistry.resolveAll(pinnedRefs, ctx), [pinnedRefs, ctx]); + const translate = t as unknown as Translate; - const recentPages = useMemo(() => pluginRegistry.resolveAll(recentRefs, ctx), [recentRefs, ctx]); + const pinnedPages = useMemo( + () => pinnedRefs.map((tab) => resolveTab(desktopRoutes, tab, false, translate)), + [pinnedRefs, translate], + ); - return { - pinnedPages, - recentPages, - }; + const recentPages = useMemo( + () => recentRefs.map((tab) => resolveTab(desktopRoutes, tab, false, translate)), + [recentRefs, translate], + ); + + return { pinnedPages, recentPages }; }; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/agentPlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/agentPlugin.ts deleted file mode 100644 index 3341aa752f..0000000000 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/agentPlugin.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { MessageSquare } from 'lucide-react'; - -import { useChatStore } from '@/store/chat'; - -import { type AgentParams, type PageReference, type ResolvedPageData } from '../types'; -import { buildAgentNewTopicAction } from './newTabHelpers'; -import { type NewTabAction, type PluginContext, type RecentlyViewedPlugin } from './types'; -import { 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; - }, - - createNewTabAction(reference: PageReference<'agent'>, ctx: PluginContext): NewTabAction | null { - return buildAgentNewTopicAction(reference.params.agentId, ctx); - }, - - 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'); - }, - - onActivate() { - useChatStore.getState().switchTopic(null); - }, - - 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/agentTopicPagePlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/agentTopicPagePlugin.ts deleted file mode 100644 index a2255e68f2..0000000000 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/agentTopicPagePlugin.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { FileText } from 'lucide-react'; - -import { getRouteById } from '@/config/routes'; -import { SESSION_CHAT_TOPIC_PAGE_URL } from '@/const/url'; -import { useChatStore } from '@/store/chat'; - -import { type AgentTopicPageParams, type PageReference, type ResolvedPageData } from '../types'; -import { type PluginContext, type RecentlyViewedPlugin } from './types'; -import { createPageReference } from './types'; - -const AGENT_TOPIC_PAGE_PATH_REGEX = /^\/agent\/([^/?]+)\/(tpc_[^/?]+)\/page(?:\/([^/?]+))?$/; - -const pageIcon = getRouteById('page')?.icon || FileText; - -export const agentTopicPagePlugin: RecentlyViewedPlugin<'agent-topic-page'> = { - checkExists(reference: PageReference<'agent-topic-page'>, ctx: PluginContext): boolean { - const { agentId, topicId } = reference.params; - const agentMeta = ctx.getAgentMeta(agentId); - const topic = ctx.getTopic(topicId); - - return agentMeta !== undefined && Object.keys(agentMeta).length > 0 && topic !== undefined; - }, - - generateId(reference: PageReference<'agent-topic-page'>): string { - const { agentId, topicId, docId } = reference.params; - return docId - ? `agent-topic-page:${agentId}:${topicId}:${docId}` - : `agent-topic-page:${agentId}:${topicId}`; - }, - - generateUrl(reference: PageReference<'agent-topic-page'>): string { - const { agentId, topicId, docId } = reference.params; - const base = SESSION_CHAT_TOPIC_PAGE_URL(agentId, topicId); - return docId ? `${base}/${docId}` : base; - }, - - getDefaultIcon() { - return pageIcon; - }, - - matchUrl(pathname: string, _searchParams: URLSearchParams): boolean { - return AGENT_TOPIC_PAGE_PATH_REGEX.test(pathname); - }, - - onActivate(reference: PageReference<'agent-topic-page'>) { - useChatStore.getState().switchTopic(reference.params.topicId); - }, - - parseUrl( - pathname: string, - _searchParams: URLSearchParams, - ): PageReference<'agent-topic-page'> | null { - const match = pathname.match(AGENT_TOPIC_PAGE_PATH_REGEX); - if (!match) return null; - - const [, agentId, topicId, docId] = match; - const params: AgentTopicPageParams = { agentId, topicId, ...(docId ? { docId } : {}) }; - const id = this.generateId({ params } as PageReference<'agent-topic-page'>); - return createPageReference('agent-topic-page', params, id); - }, - - priority: 30, - - resolve(reference: PageReference<'agent-topic-page'>, 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; - - return { - avatar: agentMeta?.avatar ?? cached?.avatar, - backgroundColor: agentMeta?.backgroundColor ?? cached?.backgroundColor, - exists: hasStoreData || cached !== undefined, - icon: this.getDefaultIcon!(), - reference, - title: - cached?.title || - topic?.title || - agentMeta?.title || - ctx.t('navigation.page', { ns: 'electron' }), - url: this.generateUrl(reference), - }; - }, - - type: 'agent-topic-page', -}; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/agentTopicPlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/agentTopicPlugin.ts deleted file mode 100644 index 77a99038bd..0000000000 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/agentTopicPlugin.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { MessageSquare } from 'lucide-react'; - -import { SESSION_CHAT_TOPIC_URL } from '@/const/url'; -import { useChatStore } from '@/store/chat'; - -import { type AgentTopicParams, type PageReference, type ResolvedPageData } from '../types'; -import { buildAgentNewTopicAction } from './newTabHelpers'; -import { type NewTabAction, type PluginContext, type RecentlyViewedPlugin } from './types'; -import { createPageReference } from './types'; - -const AGENT_PATH_REGEX = /^\/agent\/([^/?]+)$/; -const AGENT_TOPIC_PATH_REGEX = /^\/agent\/([^/?]+)\/(tpc_[^/?]+)$/; - -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; - }, - - createNewTabAction( - reference: PageReference<'agent-topic'>, - ctx: PluginContext, - ): NewTabAction | null { - return buildAgentNewTopicAction(reference.params.agentId, ctx); - }, - - 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 SESSION_CHAT_TOPIC_URL(agentId, topicId); - }, - - getDefaultIcon() { - return MessageSquare; - }, - - // Higher priority than agent plugin to match topic URLs first - matchUrl(pathname: string, searchParams: URLSearchParams): boolean { - return ( - AGENT_TOPIC_PATH_REGEX.test(pathname) || - (AGENT_PATH_REGEX.test(pathname) && searchParams.has('topic')) - ); - }, - - onActivate(reference: PageReference<'agent-topic'>) { - useChatStore.getState().switchTopic(reference.params.topicId); - }, - - parseUrl(pathname: string, searchParams: URLSearchParams): PageReference<'agent-topic'> | null { - const pathMatch = pathname.match(AGENT_TOPIC_PATH_REGEX); - - if (pathMatch) { - const [, agentId, topicId] = pathMatch; - const params: AgentTopicParams = { agentId, topicId }; - const id = this.generateId({ params } as PageReference<'agent-topic'>); - - return createPageReference('agent-topic', params, id); - } - - 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 deleted file mode 100644 index e7076bb883..0000000000 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/communityPlugin.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { ShapesIcon } from 'lucide-react'; - -import { getRouteById } from '@/config/routes'; - -import { type CommunityParams, type PageReference, type ResolvedPageData } from '../types'; -import { type PluginContext, type RecentlyViewedPlugin } from './types'; -import { 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 deleted file mode 100644 index ee5c575c34..0000000000 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/groupPlugin.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Users } from 'lucide-react'; - -import { type GroupParams, type PageReference, type ResolvedPageData } from '../types'; -import { buildGroupNewTopicAction } from './newTabHelpers'; -import { type NewTabAction, type PluginContext, type RecentlyViewedPlugin } from './types'; -import { 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; - }, - - createNewTabAction(reference: PageReference<'group'>, ctx: PluginContext): NewTabAction | null { - return buildGroupNewTopicAction(reference.params.groupId, ctx); - }, - - 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 deleted file mode 100644 index be81a996d7..0000000000 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/groupTopicPlugin.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Users } from 'lucide-react'; - -import { type GroupTopicParams, type PageReference, type ResolvedPageData } from '../types'; -import { buildGroupNewTopicAction } from './newTabHelpers'; -import { type NewTabAction, type PluginContext, type RecentlyViewedPlugin } from './types'; -import { 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; - }, - - createNewTabAction( - reference: PageReference<'group-topic'>, - ctx: PluginContext, - ): NewTabAction | null { - return buildGroupNewTopicAction(reference.params.groupId, ctx); - }, - - 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 deleted file mode 100644 index 054ab3defd..0000000000 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/homePlugin.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Home } from 'lucide-react'; - -import { type HomeParams, type PageReference, type ResolvedPageData } from '../types'; -import { type PluginContext, type RecentlyViewedPlugin } from './types'; -import { 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 deleted file mode 100644 index 81da4bf392..0000000000 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/imagePlugin.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Image } from 'lucide-react'; - -import { getRouteById } from '@/config/routes'; - -import { type ImageParams, type PageReference, type ResolvedPageData } from '../types'; -import { type PluginContext, type RecentlyViewedPlugin } from './types'; -import { 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 deleted file mode 100644 index 3bd78caa23..0000000000 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { agentPlugin } from './agentPlugin'; -import { agentTopicPagePlugin } from './agentTopicPagePlugin'; -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, - agentTopicPagePlugin, - agentTopicPlugin, - communityPlugin, - groupPlugin, - groupTopicPlugin, - homePlugin, - imagePlugin, - memoryPlugin, - pagePlugin, - resourcePlugin, - ]); - pluginRegistry.register([settingsPlugin]); -}; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/memoryPlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/memoryPlugin.ts deleted file mode 100644 index f5470cd267..0000000000 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/memoryPlugin.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Brain } from 'lucide-react'; - -import { getRouteById } from '@/config/routes'; - -import { type MemoryParams, type PageReference, type ResolvedPageData } from '../types'; -import { type PluginContext, type RecentlyViewedPlugin } from './types'; -import { 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/newTabHelpers.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/newTabHelpers.ts deleted file mode 100644 index ee3eabe6e5..0000000000 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/newTabHelpers.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { lambdaClient } from '@/libs/trpc/client'; -import { useChatStore } from '@/store/chat'; -import { usePageStore } from '@/store/page'; -import { DocumentSourceType, type LobeDocument } from '@/types/document'; - -import { type CachedPageData, type PageReference } from '../types'; -import { type NewTabAction, type NewTabActionResult, type PluginContext } from './types'; - -const EDITOR_PAGE_FILE_TYPE = 'custom/document'; - -/** - * Build a NewTabAction that creates a fresh topic under an agent and - * returns an `agent-topic` reference pointing to it. The new reference - * id embeds the topicId, which is globally unique, so it never collides - * with the existing tab it was opened from. - */ -export const buildAgentNewTopicAction = ( - agentId: string, - ctx: PluginContext, -): NewTabAction | null => { - const meta = ctx.getAgentMeta(agentId); - if (!meta || Object.keys(meta).length === 0) return null; - - return { - onCreate: async (): Promise => { - const defaultTitle = ctx.t('defaultTitle', { ns: 'topic' }); - const topicId = await lambdaClient.topic.createTopic.mutate({ - agentId, - messages: [], - title: defaultTitle, - }); - - await useChatStore.getState().refreshTopic(); - - const reference: PageReference<'agent-topic'> = { - id: `agent-topic:${agentId}:${topicId}`, - lastVisited: Date.now(), - params: { agentId, topicId }, - type: 'agent-topic', - }; - - const cached: CachedPageData = { - avatar: meta.avatar, - backgroundColor: meta.backgroundColor, - title: defaultTitle, - }; - - return { cached, reference }; - }, - }; -}; - -/** - * Build a NewTabAction that creates a fresh topic under a group and - * returns a `group-topic` reference pointing to it. - */ -export const buildGroupNewTopicAction = ( - groupId: string, - ctx: PluginContext, -): NewTabAction | null => { - const group = ctx.getSessionGroup(groupId); - if (!group) return null; - - return { - onCreate: async (): Promise => { - const defaultTitle = ctx.t('defaultTitle', { ns: 'topic' }); - const topicId = await lambdaClient.topic.createTopic.mutate({ - groupId, - messages: [], - title: defaultTitle, - }); - - await useChatStore.getState().refreshTopic(); - - const reference: PageReference<'group-topic'> = { - id: `group-topic:${groupId}:${topicId}`, - lastVisited: Date.now(), - params: { groupId, topicId }, - type: 'group-topic', - }; - - const cached: CachedPageData = { - title: defaultTitle, - }; - - return { cached, reference }; - }, - }; -}; - -/** - * Build a NewTabAction that creates a fresh untitled page document and - * returns a `page` reference pointing to it. - */ -export const buildPageNewTabAction = (ctx: PluginContext): NewTabAction => { - return { - onCreate: async (): Promise => { - const untitled = ctx.t('pageList.untitled', { ns: 'file' }); - const pageStore = usePageStore.getState(); - - // Create the real page via service first — once the row exists on - // the server, any SWR revalidation of the page list will include - // it and won't clobber the optimistic add we do below. - const newPage = await pageStore.createPage({ content: '', title: untitled }); - - // Synthesize a `LobeDocument` for the sidebar list. - const now = new Date(); - const document: LobeDocument = { - content: newPage.content || '', - createdAt: newPage.createdAt ? new Date(newPage.createdAt) : now, - editorData: - typeof newPage.editorData === 'string' - ? (() => { - try { - return JSON.parse(newPage.editorData); - } catch { - return null; - } - })() - : newPage.editorData || null, - fileType: newPage.fileType || EDITOR_PAGE_FILE_TYPE, - filename: newPage.title || untitled, - id: newPage.id, - metadata: newPage.metadata || {}, - source: 'document', - sourceType: DocumentSourceType.EDITOR, - title: newPage.title || untitled, - totalCharCount: (newPage.content || '').length, - totalLineCount: 0, - updatedAt: newPage.updatedAt ? new Date(newPage.updatedAt) : now, - }; - - // Dispatch into the sidebar list and mark selected so the nav item - // highlights in sync with the new tab. - pageStore.internal_dispatchDocuments({ document, type: 'addDocument' }); - usePageStore.setState({ selectedPageId: newPage.id }, false, 'TabBar/newPage'); - - const reference: PageReference<'page'> = { - id: `page:${newPage.id}`, - lastVisited: Date.now(), - params: { pageId: newPage.id }, - type: 'page', - }; - - const cached: CachedPageData = { - title: document.title || untitled, - }; - - return { cached, reference }; - }, - }; -}; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/pagePlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/pagePlugin.ts deleted file mode 100644 index 0813999b21..0000000000 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/pagePlugin.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { FileText } from 'lucide-react'; - -import { getRouteById } from '@/config/routes'; - -import { type PageParams, type PageReference, type ResolvedPageData } from '../types'; -import { buildPageNewTabAction } from './newTabHelpers'; -import { type NewTabAction, type PluginContext, type RecentlyViewedPlugin } from './types'; -import { 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; - }, - - createNewTabAction(_reference: PageReference<'page'>, ctx: PluginContext): NewTabAction | null { - return buildPageNewTabAction(ctx); - }, - - 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 deleted file mode 100644 index f50cae05d6..0000000000 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/registry.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { type PageReference, type PageType, type ResolvedPageData } from '../types'; -import { - type BaseRecentlyViewedPlugin, - type NewTabAction, - 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); - } - - /** - * Notify the matching plugin that a tab was activated. - * Plugins use this to perform store-level state transitions. - */ - onActivate(reference: PageReference): void { - const plugin = this.plugins.get(reference.type); - plugin?.onActivate?.(reference); - } - - /** - * Build a "new tab" action for the given reference via its plugin. - * Returns null when the plugin does not implement the extension or - * declines to produce an action for the current context. - */ - getNewTabAction(reference: PageReference, ctx: PluginContext): NewTabAction | null { - const plugin = this.plugins.get(reference.type); - return plugin?.createNewTabAction?.(reference, ctx) ?? null; - } - - /** - * 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 deleted file mode 100644 index cccc89e726..0000000000 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/resourcePlugin.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Database } from 'lucide-react'; - -import { getRouteById } from '@/config/routes'; - -import { type PageReference, type ResolvedPageData, type ResourceParams } from '../types'; -import { type PluginContext, type RecentlyViewedPlugin } from './types'; -import { 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 deleted file mode 100644 index 15bd855fb9..0000000000 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/settingsPlugin.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Settings } from 'lucide-react'; - -import { getRouteById } from '@/config/routes'; - -import { type PageReference, type ResolvedPageData, type SettingsParams } from '../types'; -import { type PluginContext, type RecentlyViewedPlugin } from './types'; -import { 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 deleted file mode 100644 index bc134033c7..0000000000 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/types.ts +++ /dev/null @@ -1,212 +0,0 @@ -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 CachedPageData, - type PageParamsMap, - type PageReference, - type PageType, - type ResolvedPageData, -} from '../types'; - -// ======== New Tab Action ======== // - -/** - * Descriptor returned by a plugin to enable the TabBar "+" button - * for a given active reference. - */ -export interface NewTabAction { - /** - * Produce a new PageReference (plus optional cached display data) for - * a fresh tab in the same context as the active tab. Return null to - * cancel the creation (e.g. missing prerequisites). - */ - onCreate: () => Promise; -} - -export interface NewTabActionResult { - cached?: CachedPageData; - reference: PageReference; -} - -// ======== 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; - - /** - * Build a "new tab" action for the TabBar "+" button. Return null to - * hide the button when this plugin's reference is active. - */ - createNewTabAction?: (reference: PageReference, ctx: PluginContext) => NewTabAction | null; - - /** - * 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; - - /** - * Called when a tab with this reference type is activated. - * Use to perform store-level state transitions (e.g. switchTopic). - */ - onActivate?: (reference: PageReference) => void; - - /** - * 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; - - /** - * Build a "new tab" action for the TabBar "+" button. Return null to - * hide the button when this plugin's reference is active. - */ - createNewTabAction?: (reference: PageReference, ctx: PluginContext) => NewTabAction | null; - - /** - * 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; - - /** - * Called when a tab with this reference type is activated. - * Use to perform store-level state transitions (e.g. switchTopic). - */ - onActivate?: (reference: PageReference) => void; - - /** - * 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 index 16ae7aeb79..78c7b8a191 100644 --- a/src/features/Electron/titlebar/RecentlyViewed/storage.ts +++ b/src/features/Electron/titlebar/RecentlyViewed/storage.ts @@ -1,11 +1,15 @@ -import { type PageReference } from './types'; +import { type TabItem } from '../TabBar/types'; -export const PINNED_PAGES_STORAGE_KEY = 'lobechat:desktop:pinned-pages:v2'; +export const PINNED_PAGES_STORAGE_KEY = 'lobechat:desktop:pinned-pages:v3'; -/** - * Get pinned pages from localStorage - */ -export const getPinnedPages = (): PageReference[] => { +const isTabItem = (item: unknown): item is TabItem => + !!item && + typeof item === 'object' && + typeof (item as TabItem).id === 'string' && + typeof (item as TabItem).url === 'string' && + typeof (item as TabItem).lastVisited === 'number'; + +export const getPinnedPages = (): TabItem[] => { if (typeof window === 'undefined') return []; try { @@ -15,25 +19,13 @@ export const getPinnedPages = (): PageReference[] => { 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, - ); + return parsed.filter(isTabItem); } catch { return []; } }; -/** - * Save pinned pages to localStorage - */ -export const savePinnedPages = (pages: PageReference[]): boolean => { +export const savePinnedPages = (pages: TabItem[]): boolean => { if (typeof window === 'undefined') return false; try { @@ -44,9 +36,6 @@ export const savePinnedPages = (pages: PageReference[]): boolean => { } }; -/** - * Clear pinned pages from localStorage - */ export const clearPinnedPages = (): boolean => { if (typeof window === 'undefined') return false; diff --git a/src/features/Electron/titlebar/RecentlyViewed/types.ts b/src/features/Electron/titlebar/RecentlyViewed/types.ts deleted file mode 100644 index 99f1b9fe63..0000000000 --- a/src/features/Electron/titlebar/RecentlyViewed/types.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { type LucideIcon } from 'lucide-react'; - -// ======== Page Types ======== // - -/** - * All supported page types for recently viewed - */ -export type PageType = - | 'agent' - | 'agent-topic' - | 'agent-topic-page' - | '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 AgentTopicPageParams { - agentId: string; - docId?: 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; -} - -export interface HomeParams {} - -/** - * Type-safe params mapping for each page type - */ -export interface PageParamsMap { - 'agent': AgentParams; - 'agent-topic': AgentTopicParams; - 'agent-topic-page': AgentTopicPageParams; - '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/features/Electron/titlebar/TabBar/TabItem.tsx b/src/features/Electron/titlebar/TabBar/TabItem.tsx index b66fad40af..49e2f73cfb 100644 --- a/src/features/Electron/titlebar/TabBar/TabItem.tsx +++ b/src/features/Electron/titlebar/TabBar/TabItem.tsx @@ -13,9 +13,9 @@ import { X } from 'lucide-react'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { type ResolvedPageData } from '@/features/Electron/titlebar/RecentlyViewed/types'; import { electronStylish } from '@/styles/electron'; +import { type ResolvedTab } from './hooks/useResolvedTabs'; import { useTabRunning } from './hooks/useTabRunning'; import { useTabUnread } from './hooks/useTabUnread'; import { useStyles } from './styles'; @@ -23,7 +23,7 @@ import { useStyles } from './styles'; interface TabItemProps { index: number; isActive: boolean; - item: ResolvedPageData; + item: ResolvedTab; onActivate: (id: string, url: string) => void; onClose: (id: string) => void; onCloseLeft: (id: string) => void; @@ -46,16 +46,17 @@ const TabItem = memo( }) => { const styles = useStyles; const { t } = useTranslation('electron'); - const id = item.reference.id; - const isRunning = useTabRunning(item.reference); - const isUnread = useTabUnread(item.reference); + const id = item.tab.id; + const { meta, tab } = item; + const isRunning = useTabRunning(tab); + const isUnread = useTabUnread(tab); const showUnreadDot = !isRunning && isUnread; const handleClick = useCallback(() => { if (!isActive) { - onActivate(id, item.url); + onActivate(id, tab.url); } - }, [isActive, onActivate, id, item.url]); + }, [isActive, onActivate, id, tab.url]); const handleClose = useCallback( (e: React.MouseEvent) => { @@ -104,12 +105,12 @@ const TabItem = memo( gap={6} onClick={handleClick} > - {item.avatar ? ( + {meta.avatar ? ( @@ -117,9 +118,9 @@ const TabItem = memo( {showUnreadDot && } ) : ( - item.icon && ( + meta.icon && ( - + {isRunning && } {showUnreadDot && ( @@ -127,7 +128,7 @@ const TabItem = memo( ) )} - {item.title} + {meta.title} diff --git a/src/features/Electron/titlebar/TabBar/hooks/useResolvedTabs.ts b/src/features/Electron/titlebar/TabBar/hooks/useResolvedTabs.ts index 2d513218ed..66cb7aaf77 100644 --- a/src/features/Electron/titlebar/TabBar/hooks/useResolvedTabs.ts +++ b/src/features/Electron/titlebar/TabBar/hooks/useResolvedTabs.ts @@ -1,35 +1,89 @@ 'use client'; import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { type RouteObject } from 'react-router-dom'; +import { desktopRoutes } from '@/spa/router/desktopRouter.config'; +import { type DynamicRouteMeta, type ResolvedRouteMeta } from '@/spa/router/routeMeta'; import { useElectronStore } from '@/store/electron'; -import { usePluginContext } from '../../RecentlyViewed/hooks/usePluginContext'; -import { pluginRegistry } from '../../RecentlyViewed/plugins'; -import { type ResolvedPageData } from '../../RecentlyViewed/types'; +import { FALLBACK_ICON, matchRouteMeta, pickMeaningful } from '../resolveRouteMeta'; +import { type TabItem } from '../types'; +import { normalizeTabUrl } from '../url'; + +export interface ResolvedTab { + isActive: boolean; + meta: ResolvedRouteMeta; + tab: TabItem; +} interface UseResolvedTabsResult { activeTabId: string | null; - tabs: ResolvedPageData[]; + tabs: ResolvedTab[]; } +type Translate = (key: string, options?: Record) => string; + +export const resolveTab = ( + routes: RouteObject[], + tab: TabItem, + isActive: boolean, + t: Translate, + liveDynamic?: DynamicRouteMeta | null, + liveDynamicTabId?: string | null, +): ResolvedTab => { + const staticMeta = matchRouteMeta(routes, tab.url).static; + + const live = isActive && liveDynamicTabId === tab.id ? liveDynamic : undefined; + + const title = + pickMeaningful(live?.title) ?? + pickMeaningful(tab.cached?.title) ?? + (staticMeta.titleKey ? t(staticMeta.titleKey, { ns: 'electron' }) : undefined) ?? + t('navigation.lobehub', { ns: 'electron' }); + + const avatar = pickMeaningful(live?.avatar) ?? pickMeaningful(tab.cached?.avatar); + const backgroundColor = + pickMeaningful(live?.backgroundColor) ?? pickMeaningful(tab.cached?.backgroundColor); + + return { + isActive, + meta: { + avatar, + backgroundColor, + icon: staticMeta.icon ?? FALLBACK_ICON, + title, + }, + tab, + }; +}; + export const useResolvedTabs = (): UseResolvedTabsResult => { - const ctx = usePluginContext(); + const { t } = useTranslation('electron'); const tabRefs = useElectronStore((s) => s.tabs); const activeTabId = useElectronStore((s) => s.activeTabId); + const currentRouteMeta = useElectronStore((s) => s.currentRouteMeta); + const currentRouteMetaUrl = useElectronStore((s) => s.currentRouteMetaUrl); - const tabs = useMemo(() => { - const results: ResolvedPageData[] = []; - for (const ref of tabRefs) { - const resolved = pluginRegistry.resolve(ref, ctx); - if (resolved) { - const cachedTitle = ref.cached?.title; - results.push(cachedTitle ? { ...resolved, title: cachedTitle } : resolved); - } - } - return results; - }, [tabRefs, ctx]); + const translate = t as unknown as Translate; + const currentRouteMetaTabId = currentRouteMetaUrl ? normalizeTabUrl(currentRouteMetaUrl) : null; + + const tabs = useMemo( + () => + tabRefs.map((tab) => + resolveTab( + desktopRoutes, + tab, + tab.id === activeTabId, + translate, + currentRouteMeta, + currentRouteMetaTabId, + ), + ), + [tabRefs, activeTabId, currentRouteMeta, currentRouteMetaTabId, translate], + ); return { activeTabId, tabs }; }; diff --git a/src/features/Electron/titlebar/TabBar/hooks/useTabRunning.ts b/src/features/Electron/titlebar/TabBar/hooks/useTabRunning.ts index c003e36fa8..39009506fd 100644 --- a/src/features/Electron/titlebar/TabBar/hooks/useTabRunning.ts +++ b/src/features/Electron/titlebar/TabBar/hooks/useTabRunning.ts @@ -1,24 +1,15 @@ -import { - type AgentParams, - type AgentTopicParams, - type PageReference, -} from '@/features/Electron/titlebar/RecentlyViewed/types'; import { useChatStore } from '@/store/chat'; import { operationSelectors } from '@/store/chat/selectors'; -/** - * Whether the agent runtime is generating in this tab's conversation context. - * Only chat tabs (agent / agent-topic) can be "running"; other tab types return false. - */ -export const useTabRunning = (reference: PageReference): boolean => +import { type TabItem } from '../types'; +import { parseAgentTabContext } from '../url'; + +export const useTabRunning = (tab: TabItem): boolean => useChatStore((s) => { - if (reference.type === 'agent') { - const { agentId } = reference.params as AgentParams; - return operationSelectors.isAgentRuntimeRunningByContext({ agentId, topicId: null })(s); - } - if (reference.type === 'agent-topic') { - const { agentId, topicId } = reference.params as AgentTopicParams; - return operationSelectors.isAgentRuntimeRunningByContext({ agentId, topicId })(s); - } - return false; + const ctx = parseAgentTabContext(tab.url); + if (!ctx) return false; + return operationSelectors.isAgentRuntimeRunningByContext({ + agentId: ctx.agentId, + topicId: ctx.topicId, + })(s); }); diff --git a/src/features/Electron/titlebar/TabBar/hooks/useTabUnread.ts b/src/features/Electron/titlebar/TabBar/hooks/useTabUnread.ts index ffbd4f4238..c460ad76e9 100644 --- a/src/features/Electron/titlebar/TabBar/hooks/useTabUnread.ts +++ b/src/features/Electron/titlebar/TabBar/hooks/useTabUnread.ts @@ -1,24 +1,13 @@ -import { - type AgentParams, - type AgentTopicParams, - type PageReference, -} from '@/features/Electron/titlebar/RecentlyViewed/types'; import { useChatStore } from '@/store/chat'; import { operationSelectors } from '@/store/chat/selectors'; -/** - * Whether this tab has an unread completed generation. - * Mirrors the sidebar agent badge, shown as a subtle dot on the tab. - */ -export const useTabUnread = (reference: PageReference): boolean => +import { type TabItem } from '../types'; +import { parseAgentTabContext } from '../url'; + +export const useTabUnread = (tab: TabItem): boolean => useChatStore((s) => { - if (reference.type === 'agent') { - const { agentId } = reference.params as AgentParams; - return operationSelectors.isAgentUnreadCompleted(agentId)(s); - } - if (reference.type === 'agent-topic') { - const { topicId } = reference.params as AgentTopicParams; - return operationSelectors.isTopicUnreadCompleted(topicId)(s); - } - return false; + const ctx = parseAgentTabContext(tab.url); + if (!ctx) return false; + if (ctx.topicId) return operationSelectors.isTopicUnreadCompleted(ctx.topicId)(s); + return operationSelectors.isAgentUnreadCompleted(ctx.agentId)(s); }); diff --git a/src/features/Electron/titlebar/TabBar/index.tsx b/src/features/Electron/titlebar/TabBar/index.tsx index a7e8dcf638..b146ae1dca 100644 --- a/src/features/Electron/titlebar/TabBar/index.tsx +++ b/src/features/Electron/titlebar/TabBar/index.tsx @@ -8,13 +8,14 @@ import { startTransition, useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { usePluginContext } from '@/features/Electron/titlebar/RecentlyViewed/hooks/usePluginContext'; -import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins'; import { electronSystemService } from '@/services/electron/system'; +import { desktopRoutes } from '@/spa/router/desktopRouter.config'; +import { type NewTabAction } from '@/spa/router/routeMeta'; import { useElectronStore } from '@/store/electron'; import { electronStylish } from '@/styles/electron'; import { useResolvedTabs } from './hooks/useResolvedTabs'; +import { matchRouteMeta } from './resolveRouteMeta'; import { useStyles } from './styles'; import TabItem from './TabItem'; @@ -27,7 +28,6 @@ const TabBar = () => { const { t } = useTranslation('electron'); const viewportRef = useRef(null); const { tabs, activeTabId } = useResolvedTabs(); - const pluginCtx = usePluginContext(); const activateTab = useElectronStore((s) => s.activateTab); const addTab = useElectronStore((s) => s.addTab); const removeTab = useElectronStore((s) => s.removeTab); @@ -37,28 +37,21 @@ const TabBar = () => { const handleActivate = useCallback( (id: string, url: string) => { - // Prioritize updating the Tab activation state (high priority) activateTab(id); - const tab = tabs.find((t) => t.reference.id === id); - if (tab) pluginRegistry.onActivate(tab.reference); - // Degrade route navigation to startTransition (low priority) startTransition(() => navigate(url)); }, - [activateTab, navigate, tabs], + [activateTab, navigate], ); const navigateToActive = useCallback(() => { const { activeTabId: newActiveId, tabs: newTabs } = useElectronStore.getState(); if (newActiveId) { - const target = newTabs.find((t) => t.id === newActiveId); - if (target) { - const resolved = tabs.find((t) => t.reference.id === newActiveId); - if (resolved) navigate(resolved.url); - } + const target = newTabs.find((tab) => tab.id === newActiveId); + if (target) navigate(target.url); } else { navigate('/'); } - }, [tabs, navigate]); + }, [navigate]); const handleClose = useCallback( (id: string) => { @@ -67,10 +60,8 @@ const TabBar = () => { startTransition(() => { if (isActive && nextActiveId) { - const nextTab = tabs.find((t) => t.reference.id === nextActiveId); - if (nextTab) { - navigate(nextTab.url); - } + const nextTab = tabs.find((tab) => tab.tab.id === nextActiveId); + if (nextTab) navigate(nextTab.tab.url); } if (!nextActiveId) { @@ -85,8 +76,8 @@ const TabBar = () => { (id: string) => { closeOtherTabs(id); startTransition(() => { - const target = tabs.find((t) => t.reference.id === id); - if (target) navigate(target.url); + const target = tabs.find((tab) => tab.tab.id === id); + if (target) navigate(target.tab.url); }); }, [closeOtherTabs, tabs, navigate], @@ -112,7 +103,7 @@ const TabBar = () => { const viewport = viewportRef.current; if (!viewport || !activeTabId) return; - const activeIndex = tabs.findIndex((t) => t.reference.id === activeTabId); + const activeIndex = tabs.findIndex((tab) => tab.tab.id === activeTabId); if (activeIndex < 0) return; const tabLeft = activeIndex * (TAB_WIDTH + TAB_GAP); @@ -126,15 +117,14 @@ const TabBar = () => { } }, [activeTabId, tabs]); - const activeReference = useMemo(() => { + const newTabAction: NewTabAction | null = useMemo(() => { if (!activeTabId) return null; - return tabs.find((t) => t.reference.id === activeTabId)?.reference ?? null; - }, [activeTabId, tabs]); + const activeTab = tabs.find((tab) => tab.tab.id === activeTabId); + if (!activeTab) return null; - const newTabAction = useMemo(() => { - if (!activeReference) return null; - return pluginRegistry.getNewTabAction(activeReference, pluginCtx); - }, [activeReference, pluginCtx]); + const matched = matchRouteMeta(desktopRoutes, activeTab.tab.url); + return matched.meta?.createNewTab?.(matched.params) ?? null; + }, [activeTabId, tabs]); useWatchBroadcast('closeCurrentTabOrWindow', () => { if (tabs.length > 1 && activeTabId) { @@ -155,14 +145,9 @@ const TabBar = () => { } if (!result) return; - const { reference, cached } = result; - addTab(reference, cached, true); - pluginRegistry.onActivate(reference); - - const resolved = pluginRegistry.resolve(reference, pluginCtx); - const url = resolved?.url; - if (url) startTransition(() => navigate(url)); - }, [newTabAction, addTab, pluginCtx, navigate]); + addTab(result.url, result.cached, true); + startTransition(() => navigate(result.url)); + }, [newTabAction, addTab, navigate]); useWatchBroadcast('createNewTab', () => { void handleNewTab(); @@ -181,9 +166,9 @@ const TabBar = () => { {tabs.map((tab, index) => ( { + it('returns the deepest static meta for a matched route', () => { + const result = matchRouteMeta(fixtureRoutes, '/agent/abc'); + expect(result.static.icon).toBe(MessageSquare); + expect(result.static.titleKey).toBe('navigation.chat'); + expect(result.params.aid).toBe('abc'); + expect(result.meta).toBe(agentMeta); + }); + + it('returns empty static meta when no handle.meta exists', () => { + const result = matchRouteMeta(fixtureRoutes, '/group/g1'); + expect(result.static.icon).toBeUndefined(); + expect(result.static.titleKey).toBeUndefined(); + expect(result.meta).toBeUndefined(); + }); + + it('returns empty static meta when no route matches', () => { + const result = matchRouteMeta(fixtureRoutes, '/nonexistent/path'); + expect(result.static).toEqual({}); + }); +}); + +describe('guardedMergeCache', () => { + it('writes only defined non-empty string fields', () => { + const merged = guardedMergeCache( + { avatar: 'a.png', title: 'Old' }, + { avatar: '', title: undefined }, + ); + expect(merged).toEqual({ avatar: 'a.png', title: 'Old' }); + }); + + it('improves the cache with meaningful values', () => { + const merged = guardedMergeCache({ title: 'Old' }, { avatar: 'a.png', title: 'New' }); + expect(merged).toEqual({ avatar: 'a.png', title: 'New' }); + }); + + it('returns prev when next is undefined', () => { + const prev = { title: 'Old' }; + expect(guardedMergeCache(prev, undefined)).toBe(prev); + }); + + it('returns undefined when nothing meaningful exists', () => { + expect(guardedMergeCache(undefined, { title: '' })).toBeUndefined(); + }); +}); + +describe('pickMeaningful', () => { + it('returns the value when non-empty', () => { + expect(pickMeaningful('x')).toBe('x'); + }); + + it('returns undefined for empty string', () => { + expect(pickMeaningful('')).toBeUndefined(); + }); + + it('returns undefined for undefined', () => { + expect(pickMeaningful(undefined)).toBeUndefined(); + }); +}); + +describe('FALLBACK_ICON', () => { + it('is exported as the generic fallback', () => { + expect(FALLBACK_ICON).toBeDefined(); + }); +}); diff --git a/src/features/Electron/titlebar/TabBar/resolveRouteMeta.ts b/src/features/Electron/titlebar/TabBar/resolveRouteMeta.ts new file mode 100644 index 0000000000..1cf325766a --- /dev/null +++ b/src/features/Electron/titlebar/TabBar/resolveRouteMeta.ts @@ -0,0 +1,51 @@ +import { Circle } from 'lucide-react'; +import { matchRoutes, type RouteObject } from 'react-router-dom'; + +import { + type DynamicRouteMeta, + getRouteMetaFromHandle, + type RouteMeta, + type StaticRouteMeta, +} from '@/spa/router/routeMeta'; + +export interface MatchedRouteMeta { + meta?: RouteMeta; + params: Record; + static: StaticRouteMeta; +} + +export const matchRouteMeta = (routes: RouteObject[], url: string): MatchedRouteMeta => { + const matches = matchRoutes(routes, url) ?? []; + const params = matches.at(-1)?.params ?? {}; + + for (let i = matches.length - 1; i >= 0; i -= 1) { + const meta = getRouteMetaFromHandle(matches[i].route.handle); + if (meta) { + return { meta, params, static: { icon: meta.icon, titleKey: meta.titleKey } }; + } + } + + return { params, static: {} }; +}; + +const isMeaningful = (value: string | undefined): value is string => + typeof value === 'string' && value.length > 0; + +export const guardedMergeCache = ( + prev: DynamicRouteMeta | undefined, + next: DynamicRouteMeta | undefined, +): DynamicRouteMeta | undefined => { + if (!next) return prev; + + const merged: DynamicRouteMeta = { ...prev }; + if (isMeaningful(next.title)) merged.title = next.title; + if (isMeaningful(next.avatar)) merged.avatar = next.avatar; + if (isMeaningful(next.backgroundColor)) merged.backgroundColor = next.backgroundColor; + + return Object.keys(merged).length > 0 ? merged : undefined; +}; + +export const FALLBACK_ICON = Circle; + +export const pickMeaningful = (value: string | undefined): string | undefined => + isMeaningful(value) ? value : undefined; diff --git a/src/features/Electron/titlebar/TabBar/resolveTab.test.ts b/src/features/Electron/titlebar/TabBar/resolveTab.test.ts new file mode 100644 index 0000000000..6dac56082f --- /dev/null +++ b/src/features/Electron/titlebar/TabBar/resolveTab.test.ts @@ -0,0 +1,110 @@ +import { Circle, MessageSquare } from 'lucide-react'; +import { type RouteObject } from 'react-router-dom'; +import { describe, expect, it } from 'vitest'; + +import { type RouteMeta } from '@/spa/router/routeMeta'; + +import { resolveTab } from './hooks/useResolvedTabs'; +import { type TabItem } from './types'; + +const agentMeta: RouteMeta = { icon: MessageSquare, titleKey: 'navigation.chat' }; + +const fixtureRoutes: RouteObject[] = [ + { + children: [{ handle: { meta: agentMeta }, path: 'agent/:aid' }, { path: 'group/:gid' }], + path: '/', + }, +]; + +const t = (key: string) => key; + +const tab = (url: string, cached?: TabItem['cached']): TabItem => ({ + cached, + id: url, + lastVisited: 1, + url, +}); + +describe('resolveTab', () => { + it('cold start: falls back to the snapshot when stores are empty', () => { + const resolved = resolveTab( + fixtureRoutes, + tab('/agent/abc', { avatar: 'a.png', title: 'Cached Agent' }), + false, + t, + ); + expect(resolved.meta.title).toBe('Cached Agent'); + expect(resolved.meta.avatar).toBe('a.png'); + expect(resolved.meta.icon).toBe(MessageSquare); + }); + + it('active tab: live dynamic meta overlays the snapshot', () => { + const resolved = resolveTab( + fixtureRoutes, + tab('/agent/abc', { title: 'Stale Cached' }), + true, + t, + { title: 'Live Title' }, + '/agent/abc', + ); + expect(resolved.meta.title).toBe('Live Title'); + }); + + it('active tab: ignores live dynamic meta resolved for another tab', () => { + const resolved = resolveTab( + fixtureRoutes, + tab('/agent/abc', { title: 'Cached Title' }), + true, + t, + { title: 'Other Live Title' }, + '/agent/def', + ); + expect(resolved.meta.title).toBe('Cached Title'); + }); + + it('inactive tab: live dynamic meta is ignored', () => { + const resolved = resolveTab( + fixtureRoutes, + tab('/agent/abc', { title: 'Cached Title' }), + false, + t, + { title: 'Live Title' }, + '/agent/abc', + ); + expect(resolved.meta.title).toBe('Cached Title'); + }); + + it('loading window: blank live title does not clobber the snapshot', () => { + const resolved = resolveTab( + fixtureRoutes, + tab('/agent/abc', { title: 'Cached Title' }), + true, + t, + { title: '' }, + '/agent/abc', + ); + expect(resolved.meta.title).toBe('Cached Title'); + }); + + it('falls back to the static titleKey when no snapshot exists', () => { + const resolved = resolveTab(fixtureRoutes, tab('/agent/abc'), false, t); + expect(resolved.meta.title).toBe('navigation.chat'); + }); + + it('uses the generic fallback when neither snapshot nor static meta exists', () => { + const resolved = resolveTab(fixtureRoutes, tab('/group/g1'), false, t); + expect(resolved.meta.title).toBe('navigation.lobehub'); + expect(resolved.meta.icon).toBe(Circle); + }); + + it('icon always comes from static route meta, never the snapshot', () => { + const resolved = resolveTab(fixtureRoutes, tab('/agent/abc', { title: 'Cached' }), false, t); + expect(resolved.meta.icon).toBe(MessageSquare); + }); + + it('does not drop a tab with undefined store data (cold start)', () => { + const resolved = resolveTab(fixtureRoutes, tab('/agent/abc'), true, t, undefined); + expect(resolved.tab.url).toBe('/agent/abc'); + expect(resolved.meta.title).toBe('navigation.chat'); + }); +}); diff --git a/src/features/Electron/titlebar/TabBar/storage.test.ts b/src/features/Electron/titlebar/TabBar/storage.test.ts new file mode 100644 index 0000000000..0c3466274e --- /dev/null +++ b/src/features/Electron/titlebar/TabBar/storage.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + getTabPages, + saveTabPages, + TAB_PAGES_STORAGE_KEY, + TAB_PAGES_STORAGE_KEY_V1, +} from './storage'; + +describe('TabBar storage', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + it('returns empty when nothing is stored', () => { + expect(getTabPages()).toEqual({ activeTabId: null, tabs: [] }); + }); + + it('round-trips v2 tab items', () => { + saveTabPages([{ id: '/agent/abc', lastVisited: 1, url: '/agent/abc' }], '/agent/abc'); + const loaded = getTabPages(); + expect(loaded.tabs).toHaveLength(1); + expect(loaded.tabs[0].url).toBe('/agent/abc'); + expect(loaded.activeTabId).toBe('/agent/abc'); + }); + + describe('v1 -> v2 migration', () => { + it('reconstructs urls from old type + params', () => { + window.localStorage.setItem( + TAB_PAGES_STORAGE_KEY_V1, + JSON.stringify({ + activeTabId: 'agent:abc', + tabs: [ + { + cached: { avatar: 'a.png', title: 'Claude' }, + id: 'agent:abc', + lastVisited: 10, + params: { agentId: 'abc' }, + type: 'agent', + }, + { + id: 'agent-topic:abc:tpc_1', + lastVisited: 20, + params: { agentId: 'abc', topicId: 'tpc_1' }, + type: 'agent-topic', + }, + { + id: 'home', + lastVisited: 5, + params: {}, + type: 'home', + }, + ], + }), + ); + + const migrated = getTabPages(); + expect(migrated.tabs.map((t) => t.url)).toEqual(['/agent/abc', '/agent/abc/tpc_1', '/']); + expect(migrated.activeTabId).toBe('/agent/abc'); + expect(migrated.tabs[0].cached).toEqual({ avatar: 'a.png', title: 'Claude' }); + }); + + it('drops tabs whose url cannot be reconstructed', () => { + window.localStorage.setItem( + TAB_PAGES_STORAGE_KEY_V1, + JSON.stringify({ + activeTabId: null, + tabs: [ + { id: 'agent:', lastVisited: 1, params: {}, type: 'agent' }, + { id: 'mystery', lastVisited: 1, params: {}, type: 'unknown-type' }, + { id: 'home', lastVisited: 1, params: {}, type: 'home' }, + ], + }), + ); + + const migrated = getTabPages(); + expect(migrated.tabs).toHaveLength(1); + expect(migrated.tabs[0].url).toBe('/'); + }); + + it('removes the v1 key after migration', () => { + window.localStorage.setItem( + TAB_PAGES_STORAGE_KEY_V1, + JSON.stringify({ activeTabId: null, tabs: [] }), + ); + getTabPages(); + expect(window.localStorage.getItem(TAB_PAGES_STORAGE_KEY_V1)).toBeNull(); + }); + + it('does not migrate when v2 data already exists', () => { + window.localStorage.setItem( + TAB_PAGES_STORAGE_KEY, + JSON.stringify({ + activeTabId: '/page/p1', + tabs: [{ id: '/page/p1', lastVisited: 1, url: '/page/p1' }], + }), + ); + window.localStorage.setItem( + TAB_PAGES_STORAGE_KEY_V1, + JSON.stringify({ + activeTabId: 'agent:abc', + tabs: [{ id: 'agent:abc', lastVisited: 1, params: { agentId: 'abc' }, type: 'agent' }], + }), + ); + + const loaded = getTabPages(); + expect(loaded.tabs).toHaveLength(1); + expect(loaded.tabs[0].url).toBe('/page/p1'); + }); + }); +}); diff --git a/src/features/Electron/titlebar/TabBar/storage.ts b/src/features/Electron/titlebar/TabBar/storage.ts index c053469421..544bdb63ff 100644 --- a/src/features/Electron/titlebar/TabBar/storage.ts +++ b/src/features/Electron/titlebar/TabBar/storage.ts @@ -1,44 +1,146 @@ -import { type PageReference } from '@/features/Electron/titlebar/RecentlyViewed/types'; +import { type DynamicRouteMeta } from '@/spa/router/routeMeta'; -export const TAB_PAGES_STORAGE_KEY = 'lobechat:desktop:tab-pages:v1'; +import { type TabItem } from './types'; +import { normalizeTabUrl } from './url'; + +export const TAB_PAGES_STORAGE_KEY_V1 = 'lobechat:desktop:tab-pages:v1'; +export const TAB_PAGES_STORAGE_KEY = 'lobechat:desktop:tab-pages:v2'; interface TabPagesStorageData { activeTabId: string | null; - tabs: PageReference[]; + tabs: TabItem[]; } +const EMPTY: TabPagesStorageData = { activeTabId: null, tabs: [] }; + +const isTabItem = (item: unknown): item is TabItem => + !!item && + typeof item === 'object' && + typeof (item as TabItem).id === 'string' && + typeof (item as TabItem).url === 'string' && + typeof (item as TabItem).lastVisited === 'number'; + +const reconstructUrlFromV1 = (type: unknown, params: unknown): string | null => { + if (typeof type !== 'string' || !params || typeof params !== 'object') return null; + const p = params as Record; + + switch (type) { + case 'home': { + return '/'; + } + case 'agent': { + return p.agentId ? `/agent/${p.agentId}` : null; + } + case 'agent-topic': { + return p.agentId && p.topicId ? `/agent/${p.agentId}/${p.topicId}` : null; + } + case 'agent-topic-page': { + if (!p.agentId || !p.topicId) return null; + return p.docId + ? `/agent/${p.agentId}/${p.topicId}/page/${p.docId}` + : `/agent/${p.agentId}/${p.topicId}/page`; + } + case 'group': { + return p.groupId ? `/group/${p.groupId}` : null; + } + case 'group-topic': { + return p.groupId && p.topicId ? `/group/${p.groupId}?topic=${p.topicId}` : null; + } + case 'page': { + return p.pageId ? `/page/${p.pageId}` : null; + } + case 'settings': { + return p.section ? `/settings/${p.section}` : '/settings'; + } + case 'community': { + return p.section ? `/community/${p.section}` : '/community'; + } + case 'resource': { + return p.section ? `/resource/${p.section}` : '/resource'; + } + case 'memory': { + return p.section ? `/memory/${p.section}` : '/memory'; + } + case 'image': { + return '/image'; + } + default: { + return null; + } + } +}; + +const migrateV1 = (): TabPagesStorageData => { + if (typeof window === 'undefined') return EMPTY; + + try { + const raw = window.localStorage.getItem(TAB_PAGES_STORAGE_KEY_V1); + if (!raw) return EMPTY; + + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.tabs)) return EMPTY; + + const seen = new Set(); + const tabs: TabItem[] = []; + let activeTabId: string | null = null; + + for (const old of parsed.tabs) { + if (!old || typeof old !== 'object') continue; + const url = reconstructUrlFromV1(old.type, old.params); + if (!url) continue; + + const id = normalizeTabUrl(url); + if (seen.has(id)) continue; + seen.add(id); + + const cached = + old.cached && typeof old.cached === 'object' ? (old.cached as DynamicRouteMeta) : undefined; + + tabs.push({ + cached, + id, + lastVisited: typeof old.lastVisited === 'number' ? old.lastVisited : Date.now(), + url, + visitCount: typeof old.visitCount === 'number' ? old.visitCount : undefined, + }); + + if (old.id === parsed.activeTabId) activeTabId = id; + } + + return { activeTabId, tabs }; + } catch { + return EMPTY; + } finally { + try { + window.localStorage.removeItem(TAB_PAGES_STORAGE_KEY_V1); + } catch { + // ignore + } + } +}; + export const getTabPages = (): TabPagesStorageData => { - if (typeof window === 'undefined') return { activeTabId: null, tabs: [] }; + if (typeof window === 'undefined') return EMPTY; try { const data = window.localStorage.getItem(TAB_PAGES_STORAGE_KEY); - if (!data) return { activeTabId: null, tabs: [] }; + if (!data) return migrateV1(); const parsed = JSON.parse(data); - if (!parsed || typeof parsed !== 'object') return { activeTabId: null, tabs: [] }; + if (!parsed || typeof parsed !== 'object') return EMPTY; - const tabs = Array.isArray(parsed.tabs) - ? parsed.tabs.filter( - (item: any): item is PageReference => - item && - typeof item === 'object' && - typeof item.id === 'string' && - typeof item.type === 'string' && - typeof item.lastVisited === 'number' && - item.params !== undefined, - ) - : []; + const tabs = Array.isArray(parsed.tabs) ? parsed.tabs.filter(isTabItem) : []; return { activeTabId: typeof parsed.activeTabId === 'string' ? parsed.activeTabId : null, tabs, }; } catch { - return { activeTabId: null, tabs: [] }; + return EMPTY; } }; -export const saveTabPages = (tabs: PageReference[], activeTabId: string | null): boolean => { +export const saveTabPages = (tabs: TabItem[], activeTabId: string | null): boolean => { if (typeof window === 'undefined') return false; try { diff --git a/src/features/Electron/titlebar/TabBar/types.ts b/src/features/Electron/titlebar/TabBar/types.ts new file mode 100644 index 0000000000..b2cdd3f41a --- /dev/null +++ b/src/features/Electron/titlebar/TabBar/types.ts @@ -0,0 +1,9 @@ +import { type DynamicRouteMeta } from '@/spa/router/routeMeta'; + +export interface TabItem { + cached?: DynamicRouteMeta; + id: string; + lastVisited: number; + url: string; + visitCount?: number; +} diff --git a/src/features/Electron/titlebar/TabBar/url.test.ts b/src/features/Electron/titlebar/TabBar/url.test.ts new file mode 100644 index 0000000000..86a7b3f99f --- /dev/null +++ b/src/features/Electron/titlebar/TabBar/url.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeTabUrl, parseAgentTabContext } from './url'; + +describe('normalizeTabUrl', () => { + it('keeps a plain pathname', () => { + expect(normalizeTabUrl('/agent/abc')).toBe('/agent/abc'); + }); + + it('strips a trailing slash', () => { + expect(normalizeTabUrl('/agent/abc/')).toBe('/agent/abc'); + }); + + it('keeps the root path intact', () => { + expect(normalizeTabUrl('/')).toBe('/'); + }); + + it('normalizes search param ordering', () => { + expect(normalizeTabUrl('/agent/abc?b=2&a=1')).toBe('/agent/abc?a=1&b=2'); + }); + + it('keeps all search params (identity-significant)', () => { + expect(normalizeTabUrl('/group/g1?topic=t1')).toBe('/group/g1?topic=t1'); + }); + + it('drops the hash fragment', () => { + expect(normalizeTabUrl('/agent/abc?a=1#section')).toBe('/agent/abc?a=1'); + }); + + it('makes equivalent URLs collapse to the same id', () => { + expect(normalizeTabUrl('/agent/abc?a=1&b=2')).toBe(normalizeTabUrl('/agent/abc?b=2&a=1')); + }); +}); + +describe('parseAgentTabContext', () => { + it('parses a bare agent url', () => { + expect(parseAgentTabContext('/agent/abc')).toEqual({ agentId: 'abc', topicId: null }); + }); + + it('parses an agent topic path url', () => { + expect(parseAgentTabContext('/agent/abc/tpc_xyz')).toEqual({ + agentId: 'abc', + topicId: 'tpc_xyz', + }); + }); + + it('parses topic from the search param', () => { + expect(parseAgentTabContext('/agent/abc?topic=t1')).toEqual({ + agentId: 'abc', + topicId: 't1', + }); + }); + + it('returns null for non-agent urls', () => { + expect(parseAgentTabContext('/group/g1')).toBeNull(); + }); +}); diff --git a/src/features/Electron/titlebar/TabBar/url.ts b/src/features/Electron/titlebar/TabBar/url.ts new file mode 100644 index 0000000000..c45e1cfa25 --- /dev/null +++ b/src/features/Electron/titlebar/TabBar/url.ts @@ -0,0 +1,42 @@ +export const normalizeTabUrl = (url: string): string => { + const [rawPath = '', rawQuery = ''] = url.split('?'); + + let pathname = rawPath || '/'; + if (pathname.length > 1 && pathname.endsWith('/')) { + pathname = pathname.replace(/\/+$/, '') || '/'; + } + if (!pathname.startsWith('/')) pathname = `/${pathname}`; + + const queryString = rawQuery.split('#')[0] ?? ''; + if (!queryString) return pathname; + + const params = new URLSearchParams(queryString); + const entries = [...params.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + if (entries.length === 0) return pathname; + + const sorted = new URLSearchParams(); + for (const [key, value] of entries) sorted.append(key, value); + + return `${pathname}?${sorted.toString()}`; +}; + +export interface AgentTabContext { + agentId: string; + topicId: string | null; +} + +const AGENT_TOPIC_PATH = /^\/agent\/([^/]+)\/(tpc_[^/]+)(?:\/|$)/; +const AGENT_PATH = /^\/agent\/([^/]+)(?:\/|$)/; + +export const parseAgentTabContext = (url: string): AgentTabContext | null => { + const [rawPath = '', rawQuery = ''] = url.split('?'); + + const topicMatch = rawPath.match(AGENT_TOPIC_PATH); + if (topicMatch) return { agentId: topicMatch[1], topicId: topicMatch[2] }; + + const agentMatch = rawPath.match(AGENT_PATH); + if (!agentMatch) return null; + + const queryTopic = new URLSearchParams(rawQuery.split('#')[0] ?? '').get('topic'); + return { agentId: agentMatch[1], topicId: queryTopic || null }; +}; diff --git a/src/features/PageEditor/PageEditor.tsx b/src/features/PageEditor/PageEditor.tsx index 522a6fbd96..4fcfad6bcb 100644 --- a/src/features/PageEditor/PageEditor.tsx +++ b/src/features/PageEditor/PageEditor.tsx @@ -16,7 +16,6 @@ import EditorCanvas from './EditorCanvas'; import Header from './Header'; import { PageAgentProvider } from './PageAgentProvider'; import { PageEditorProvider } from './PageEditorProvider'; -import PageTitle from './PageTitle'; import RightPanel from './RightPanel'; import { usePageEditorStore } from './store'; import TitleSection from './TitleSection'; @@ -99,36 +98,26 @@ const PageEditorCanvas = memo(({ header, fullWidthHeader if (fullWidthHeader) { return ( - <> - - - {headerSlot} - - {editorPane} - - + + {headerSlot} + + {editorPane} + - + ); } return ( - <> - - - {editorPane} - - - + + {editorPane} + + ); }); diff --git a/src/features/PageEditor/PageTitle/index.tsx b/src/features/PageEditor/PageTitle/index.tsx deleted file mode 100644 index 54ec721062..0000000000 --- a/src/features/PageEditor/PageTitle/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -'use client'; - -import { memo } from 'react'; - -import PageTitle from '@/components/PageTitle'; -import { selectors, usePageEditorStore } from '@/features/PageEditor/store'; - -const Title = memo(() => { - const pageTitle = usePageEditorStore(selectors.title); - return pageTitle && ; -}); - -export default Title; diff --git a/src/features/Pages/PageLayout/Body/List/Item/index.tsx b/src/features/Pages/PageLayout/Body/List/Item/index.tsx index 33884d718d..faf33939b1 100644 --- a/src/features/Pages/PageLayout/Body/List/Item/index.tsx +++ b/src/features/Pages/PageLayout/Body/List/Item/index.tsx @@ -5,7 +5,6 @@ import { memo, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { isDesktop } from '@/const/version'; -import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins'; import NavItem from '@/features/NavPanel/components/NavItem'; import { useElectronStore } from '@/store/electron'; import { pageSelectors, usePageStore } from '@/store/page'; @@ -67,11 +66,8 @@ const PageListItem = memo(({ pageId, className }) => { clearTimeout(clickTimerRef.current); clickTimerRef.current = null; } - const reference = pluginRegistry.parseUrl(`/page/${pageId}`, ''); - if (reference) { - addTab(reference); - selectPage(pageId); - } + addTab(`/page/${pageId}`); + selectPage(pageId); }, [pageId, addTab, selectPage]); // Icon with emoji support diff --git a/src/features/Pages/PageLayout/Body/List/Item/useDropdownMenu.tsx b/src/features/Pages/PageLayout/Body/List/Item/useDropdownMenu.tsx index 777bbdd1b5..768424b362 100644 --- a/src/features/Pages/PageLayout/Body/List/Item/useDropdownMenu.tsx +++ b/src/features/Pages/PageLayout/Body/List/Item/useDropdownMenu.tsx @@ -7,7 +7,6 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { isDesktop } from '@/const/version'; -import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins'; import { useElectronStore } from '@/store/electron'; import { usePageStore } from '@/store/page'; @@ -65,11 +64,8 @@ export const useDropdownMenu = ({ label: t('pageList.actions.openInNewTab', { ns: 'file' }), onClick: () => { const url = `/page/${pageId}`; - const reference = pluginRegistry.parseUrl(url, ''); - if (reference) { - addTab(reference); - navigate(url); - } + addTab(url); + navigate(url); }, }, { type: 'divider' as const }, diff --git a/src/features/Pages/PageTitle.tsx b/src/features/Pages/PageTitle.tsx deleted file mode 100644 index a0194140c4..0000000000 --- a/src/features/Pages/PageTitle.tsx +++ /dev/null @@ -1,13 +0,0 @@ -'use client'; - -import { memo } from 'react'; - -import PageTitle from '@/components/PageTitle'; - -const Title = memo(() => { - return ; -}); - -Title.displayName = 'PageTitle'; - -export default Title; diff --git a/src/features/Pages/index.ts b/src/features/Pages/index.ts index 07f0acb15c..2efcb297fe 100644 --- a/src/features/Pages/index.ts +++ b/src/features/Pages/index.ts @@ -1,2 +1 @@ export { default as PageLayout } from './PageLayout'; -export { default as PageTitle } from './PageTitle'; diff --git a/src/features/Pages/routeMeta.ts b/src/features/Pages/routeMeta.ts new file mode 100644 index 0000000000..98567ab871 --- /dev/null +++ b/src/features/Pages/routeMeta.ts @@ -0,0 +1,65 @@ +import { t } from 'i18next'; +import { FilePenIcon } from 'lucide-react'; + +import { type DynamicRouteMeta, routeMeta } from '@/spa/router/routeMeta'; +import { usePageStore } from '@/store/page'; +import { listSelectors } from '@/store/page/slices/list/selectors'; +import { DocumentSourceType, type LobeDocument } from '@/types/document'; +import { getIdFromIdentifier } from '@/utils/identifier'; + +const EDITOR_PAGE_FILE_TYPE = 'custom/document'; + +export const pageRouteMeta = routeMeta({ + createNewTab: () => ({ + onCreate: async () => { + const untitled = t('pageList.untitled', { ns: 'file' }); + const pageStore = usePageStore.getState(); + + const newPage = await pageStore.createPage({ content: '', title: untitled }); + + const now = new Date(); + const document: LobeDocument = { + content: newPage.content || '', + createdAt: newPage.createdAt ? new Date(newPage.createdAt) : now, + editorData: + typeof newPage.editorData === 'string' + ? (() => { + try { + return JSON.parse(newPage.editorData); + } catch { + return null; + } + })() + : newPage.editorData || null, + fileType: newPage.fileType || EDITOR_PAGE_FILE_TYPE, + filename: newPage.title || untitled, + id: newPage.id, + metadata: newPage.metadata || {}, + source: 'document', + sourceType: DocumentSourceType.EDITOR, + title: newPage.title || untitled, + totalCharCount: (newPage.content || '').length, + totalLineCount: 0, + updatedAt: newPage.updatedAt ? new Date(newPage.updatedAt) : now, + }; + + pageStore.internal_dispatchDocuments({ document, type: 'addDocument' }); + usePageStore.setState({ selectedPageId: newPage.id }, false, 'TabBar/newPage'); + + return { + cached: { title: document.title || untitled }, + url: `/page/${newPage.id}`, + }; + }, + }), + icon: FilePenIcon, + titleKey: 'navigation.page', + useDynamicMeta: (params): DynamicRouteMeta => { + const pageId = params.id ? getIdFromIdentifier(params.id, 'docs') : ''; + const document = usePageStore(listSelectors.getDocumentById(pageId)); + + return { + title: document?.title || undefined, + }; + }, +}); diff --git a/src/features/ResourceManager/index.tsx b/src/features/ResourceManager/index.tsx index 1e057a5d30..a4ee0d8ca4 100644 --- a/src/features/ResourceManager/index.tsx +++ b/src/features/ResourceManager/index.tsx @@ -1,6 +1,5 @@ 'use client'; -import { BRANDING_NAME } from '@lobechat/business-const'; import { Flexbox } from '@lobehub/ui'; import { createStaticStyles, useTheme } from 'antd-style'; import { memo, useCallback, useEffect, useMemo } from 'react'; @@ -109,8 +108,6 @@ const ResourceManager = memo(() => { prev.delete('file'); return prev; }); - // Reset document title to default - document.title = BRANDING_NAME; }; // Optimistic update handlers for page title and emoji diff --git a/src/features/RouteMeta/DynamicMetaRunner.tsx b/src/features/RouteMeta/DynamicMetaRunner.tsx new file mode 100644 index 0000000000..a451364e56 --- /dev/null +++ b/src/features/RouteMeta/DynamicMetaRunner.tsx @@ -0,0 +1,42 @@ +'use client'; + +import debug from 'debug'; +import { memo, useEffect } from 'react'; + +import { SafeBoundary } from '@/components/ErrorBoundary'; +import { type DynamicRouteMeta, type RouteMeta } from '@/spa/router/routeMeta'; + +const log = debug('lobe-client:route-meta'); + +interface DynamicMetaRunnerProps { + onResolve: (meta: DynamicRouteMeta) => void; + params: Record; + useDynamicMeta?: RouteMeta['useDynamicMeta']; +} + +const Runner = memo(({ useDynamicMeta, params, onResolve }) => { + const { avatar, backgroundColor, title } = useDynamicMeta?.(params) ?? {}; + + useEffect(() => { + onResolve({ avatar, backgroundColor, title }); + }, [avatar, backgroundColor, title, onResolve]); + + return null; +}); + +Runner.displayName = 'DynamicMetaRunner'; + +const DynamicMetaRunner = memo((props) => ( + { + log('useDynamicMeta threw, falling back to static meta: %O', error); + props.onResolve({}); + }} + > + + +)); + +DynamicMetaRunner.displayName = 'DynamicMetaRunnerBoundary'; + +export default DynamicMetaRunner; diff --git a/src/features/RouteMeta/RouteMetaBridge.test.tsx b/src/features/RouteMeta/RouteMetaBridge.test.tsx new file mode 100644 index 0000000000..f968ca9388 --- /dev/null +++ b/src/features/RouteMeta/RouteMetaBridge.test.tsx @@ -0,0 +1,124 @@ +import { BRANDING_NAME } from '@lobechat/business-const'; +import { act, cleanup, render, waitFor } from '@testing-library/react'; +import type * as ReactModule from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import RouteMetaBridge from './RouteMetaBridge'; + +const mocks = vi.hoisted(() => { + interface MockMatch { + data: unknown; + handle: unknown; + id: string; + params: Record; + pathname: string; + } + + const store = { + listeners: new Set<() => void>(), + matches: [] as MockMatch[], + setMatches: (matches: MockMatch[]) => { + store.matches = matches; + for (const listener of store.listeners) { + listener(); + } + }, + }; + + return { + getSnapshot: () => store.matches, + setCurrentRouteMeta: vi.fn(), + setMatches: store.setMatches, + subscribe: (listener: () => void) => { + store.listeners.add(listener); + return () => { + store.listeners.delete(listener); + }; + }, + }; +}); + +vi.mock('@/const/version', () => ({ + isDesktop: true, +})); + +vi.mock('@/store/electron', () => ({ + useElectronStore: ( + selector: (state: { setCurrentRouteMeta: typeof mocks.setCurrentRouteMeta }) => unknown, + ) => selector({ setCurrentRouteMeta: mocks.setCurrentRouteMeta }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => `translated:${key}` }), +})); + +vi.mock('react-router-dom', async () => { + const React = await vi.importActual('react'); + + return { + useMatches: () => React.useSyncExternalStore(mocks.subscribe, mocks.getSnapshot), + useLocation: () => { + const matches = React.useSyncExternalStore(mocks.subscribe, mocks.getSnapshot); + return { pathname: matches.at(-1)?.pathname ?? '/', search: '' }; + }, + }; +}); + +describe('RouteMetaBridge', () => { + const resolveDynamicMeta = () => ({ title: 'Chat A' }); + + afterEach(() => { + cleanup(); + document.title = ''; + mocks.setMatches([]); + mocks.setCurrentRouteMeta.mockReset(); + }); + + it('clears dynamic meta when the matched route has no route meta handle', async () => { + mocks.setMatches([ + { + data: undefined, + handle: { + meta: { + titleKey: 'navigation.chat', + useDynamicMeta: resolveDynamicMeta, + }, + }, + id: 'routes/agent', + params: { aid: 'agent-a' }, + pathname: '/agent/agent-a', + }, + ]); + + render(); + + await waitFor(() => { + expect(document.title).toBe(`Chat A · ${BRANDING_NAME}`); + expect(mocks.setCurrentRouteMeta).toHaveBeenLastCalledWith( + { + avatar: undefined, + backgroundColor: undefined, + title: 'Chat A', + }, + '/agent/agent-a', + ); + }); + + act(() => { + mocks.setMatches([ + { + data: undefined, + handle: undefined, + id: 'routes/agent-profile', + params: { aid: 'agent-a' }, + pathname: '/agent/agent-a/profile', + }, + ]); + }); + + await waitFor(() => { + expect(document.title).toBe(BRANDING_NAME); + expect(mocks.setCurrentRouteMeta).toHaveBeenLastCalledWith(null); + }); + }); +}); diff --git a/src/features/RouteMeta/RouteMetaBridge.tsx b/src/features/RouteMeta/RouteMetaBridge.tsx new file mode 100644 index 0000000000..76e914dbf9 --- /dev/null +++ b/src/features/RouteMeta/RouteMetaBridge.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { BRANDING_NAME } from '@lobechat/business-const'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useLocation, useMatches } from 'react-router-dom'; + +import { isDesktop } from '@/const/version'; +import { + type DynamicRouteMeta, + getRouteMetaFromHandle, + type RouteMeta, +} from '@/spa/router/routeMeta'; +import { useElectronStore } from '@/store/electron'; + +import DynamicMetaRunner from './DynamicMetaRunner'; + +interface MatchedRouteMeta { + meta: RouteMeta; + params: Record; + routeId: string; +} + +interface DynamicRouteMetaState { + matchKey: string | null; + meta: DynamicRouteMeta; +} + +const useMatchedRouteMeta = (): MatchedRouteMeta | null => { + const matches = useMatches(); + + return useMemo(() => { + for (let i = matches.length - 1; i >= 0; i -= 1) { + const match = matches[i]; + const meta = getRouteMetaFromHandle(match.handle); + if (meta) { + return { meta, params: match.params, routeId: match.id }; + } + } + return null; + }, [matches]); +}; + +type Translate = (key: string) => string; + +const RouteMetaBridge = memo(() => { + const { t } = useTranslation('electron'); + const location = useLocation(); + const setCurrentRouteMeta = useElectronStore((s) => s.setCurrentRouteMeta); + const matched = useMatchedRouteMeta(); + const currentUrl = location.pathname + location.search; + const matchedKey = matched ? `${matched.routeId}:${currentUrl}` : null; + const [dynamic, setDynamic] = useState({ matchKey: null, meta: {} }); + + const handleResolve = useCallback( + (resolved: DynamicRouteMeta) => { + setDynamic({ matchKey: matchedKey, meta: resolved }); + if (isDesktop) setCurrentRouteMeta(resolved, currentUrl); + }, + [currentUrl, matchedKey, setCurrentRouteMeta], + ); + + const translate = t as unknown as Translate; + const titleKey = matched?.meta.titleKey; + const currentDynamic = dynamic.matchKey === matchedKey ? dynamic.meta : {}; + const title = matched ? currentDynamic.title || (titleKey ? translate(titleKey) : '') : ''; + + useEffect(() => { + if (matchedKey) return; + + setDynamic({ matchKey: null, meta: {} }); + if (isDesktop) setCurrentRouteMeta(null); + }, [matchedKey, setCurrentRouteMeta]); + + useEffect(() => { + document.title = title ? `${title} · ${BRANDING_NAME}` : BRANDING_NAME; + }, [title]); + + if (!matched) return null; + + return ( + + ); +}); + +RouteMetaBridge.displayName = 'RouteMetaBridge'; + +export default RouteMetaBridge; diff --git a/src/features/RouteMeta/index.ts b/src/features/RouteMeta/index.ts new file mode 100644 index 0000000000..fdd8dc48db --- /dev/null +++ b/src/features/RouteMeta/index.ts @@ -0,0 +1 @@ +export { default as RouteMetaBridge } from './RouteMetaBridge'; diff --git a/src/features/RouteMeta/mobileRouteMeta.ts b/src/features/RouteMeta/mobileRouteMeta.ts new file mode 100644 index 0000000000..3623c318f9 --- /dev/null +++ b/src/features/RouteMeta/mobileRouteMeta.ts @@ -0,0 +1,39 @@ +import { t } from 'i18next'; +import { MessageSquare, Settings } from 'lucide-react'; +import useSWR from 'swr'; + +import { lambdaClient } from '@/libs/trpc/client'; +import { routeMeta } from '@/spa/router/routeMeta'; +import { useAgentStore } from '@/store/agent'; +import { agentSelectors } from '@/store/agent/selectors'; + +export const mobileAgentSettingsRouteMeta = routeMeta({ + icon: Settings, + titleKey: 'navigation.chat', + useDynamicMeta: (params) => { + const meta = useAgentStore(agentSelectors.getAgentMetaById(params.aid ?? '')); + + return { + title: meta.title + ? t('header.sessionWithName', { name: meta.title, ns: 'setting' }) + : t('header.session', { ns: 'setting' }), + }; + }, +}); + +export const shareTopicRouteMeta = routeMeta({ + icon: MessageSquare, + titleKey: 'navigation.chat', + useDynamicMeta: (params) => { + const shareId = params.id; + const { data } = useSWR( + shareId ? ['shared-topic', shareId] : null, + () => lambdaClient.share.getSharedTopic.query({ shareId: shareId! }), + { revalidateOnFocus: false }, + ); + + return { + title: data?.title || undefined, + }; + }, +}); diff --git a/src/locales/default/electron.ts b/src/locales/default/electron.ts index 4a5a6cb25a..f88d889106 100644 --- a/src/locales/default/electron.ts +++ b/src/locales/default/electron.ts @@ -26,6 +26,7 @@ export default { 'navigation.recentView': 'Recent pages', 'navigation.resources': 'Resources', 'navigation.settings': 'Settings', + 'navigation.task': 'Task', 'navigation.tasks': 'Tasks', 'navigation.unpin': 'Unpin', 'notification.finishChatGeneration': 'AI message generation completed', diff --git a/src/routes/(main)/_layout/index.tsx b/src/routes/(main)/_layout/index.tsx index 78e5236aea..4ceff8a010 100644 --- a/src/routes/(main)/_layout/index.tsx +++ b/src/routes/(main)/_layout/index.tsx @@ -21,6 +21,7 @@ import OverlaySnapshotPublisher from '@/features/Electron/ScreenCapture/OverlayS import TitleBar from '@/features/Electron/titlebar/TitleBar'; import HotkeyHelperPanel from '@/features/HotkeyHelperPanel'; import NavPanel from '@/features/NavPanel'; +import { RouteMetaBridge } from '@/features/RouteMeta'; import { useFeedbackModal } from '@/hooks/useFeedbackModal'; import { usePlatform } from '@/hooks/usePlatform'; import { MarketAuthProvider } from '@/layout/AuthProvider/MarketAuth'; @@ -51,6 +52,7 @@ const Layout: FC = () => { return ( + {isDesktop && } {isDesktop && } diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.test.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.test.tsx index 7429a881ba..60ad099ea2 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.test.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.test.tsx @@ -63,9 +63,6 @@ vi.mock('@/const/version', () => ({ isDesktop: false })); vi.mock('@/const/url', () => ({ SESSION_CHAT_TOPIC_URL: (agentId: string, topicId: string) => `/agent/${agentId}/${topicId}`, })); -vi.mock('@/features/Electron/titlebar/RecentlyViewed/plugins', () => ({ - pluginRegistry: { parseUrl: vi.fn() }, -})); vi.mock('@/features/NavPanel/components/NavItem', () => ({ default: ({ active, title }: { active?: boolean; title?: ReactNode }) => (
diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx index 9945296493..7b947d4e5d 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx @@ -9,7 +9,6 @@ import DotsLoading from '@/components/DotsLoading'; import RingLoadingIcon from '@/components/RingLoading'; import { SESSION_CHAT_TOPIC_URL } from '@/const/url'; import { isDesktop } from '@/const/version'; -import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins'; import NavItem from '@/features/NavPanel/components/NavItem'; import { getPlatformIcon } from '@/routes/(main)/agent/channel/const'; import { useAgentStore } from '@/store/agent'; @@ -164,12 +163,8 @@ const TopicItem = memo(({ id, title, fav, active, threadId, meta void navigateToTopic(id, { skipPopupFocus: true }); return; } - const url = SESSION_CHAT_TOPIC_URL(activeAgentId, id); - const reference = pluginRegistry.parseUrl(url, ''); - if (reference) { - addTab(reference); - void navigateToTopic(id); - } + addTab(SESSION_CHAT_TOPIC_URL(activeAgentId, id)); + void navigateToTopic(id); }, [id, activeAgentId, addTab, focusTopicPopup, navigateToTopic]); const { dropdownMenu } = useTopicItemDropdownMenu({ diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx index 4afaaed50d..b560dcb241 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx @@ -23,7 +23,6 @@ import { useNavigate } from 'react-router-dom'; import { openRenameModal } from '@/components/RenameModal'; import { SESSION_CHAT_TOPIC_URL } from '@/const/url'; import { isDesktop } from '@/const/version'; -import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins'; import { openShareModal } from '@/features/ShareModal'; import { useAppOrigin } from '@/hooks/useAppOrigin'; import { useAgentStore } from '@/store/agent'; @@ -143,11 +142,8 @@ export const useTopicItemDropdownMenu = ({ onClick: () => { if (!activeAgentId) return; const url = SESSION_CHAT_TOPIC_URL(activeAgentId, id); - const reference = pluginRegistry.parseUrl(url, ''); - if (reference) { - addTab(reference); - navigate(url); - } + addTab(url); + navigate(url); }, }, { diff --git a/src/routes/(main)/agent/features/PageTitle/index.tsx b/src/routes/(main)/agent/features/PageTitle/index.tsx deleted file mode 100644 index 60e97a1d17..0000000000 --- a/src/routes/(main)/agent/features/PageTitle/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -'use client'; - -import { memo } from 'react'; - -import PageTitle from '@/components/PageTitle'; -import { useAgentStore } from '@/store/agent'; -import { agentSelectors } from '@/store/agent/selectors'; -import { useChatStore } from '@/store/chat'; -import { topicSelectors } from '@/store/chat/selectors'; - -const Title = memo(() => { - const agentTitle = useAgentStore(agentSelectors.currentAgentTitle); - - const topicTitle = useChatStore((s) => topicSelectors.currentActiveTopic(s)?.title); - return ; -}); - -export default Title; diff --git a/src/routes/(main)/agent/features/routeMeta.ts b/src/routes/(main)/agent/features/routeMeta.ts new file mode 100644 index 0000000000..2c02329a71 --- /dev/null +++ b/src/routes/(main)/agent/features/routeMeta.ts @@ -0,0 +1,64 @@ +import { t } from 'i18next'; +import { MessageSquare } from 'lucide-react'; + +import { lambdaClient } from '@/libs/trpc/client'; +import { type DynamicRouteMeta, routeMeta } from '@/spa/router/routeMeta'; +import { useAgentStore } from '@/store/agent'; +import { agentSelectors } from '@/store/agent/selectors'; +import { useChatStore } from '@/store/chat'; + +const useTopicTitle = (topicId: string | undefined): string | undefined => + useChatStore((s) => { + if (!topicId) return undefined; + for (const data of Object.values(s.topicDataMap)) { + const topic = data.items?.find((item) => item.id === topicId); + if (topic?.title) return topic.title; + } + return undefined; + }); + +export const agentRouteMeta = routeMeta({ + createNewTab: (params) => { + const agentId = params.aid; + if (!agentId) return null; + + return { + onCreate: async () => { + const meta = agentSelectors.getAgentMetaById(agentId)(useAgentStore.getState()); + if (!meta || Object.keys(meta).length === 0) return null; + + const defaultTitle = t('defaultTitle', { ns: 'topic' }); + const topicId = await lambdaClient.topic.createTopic.mutate({ + agentId, + messages: [], + title: defaultTitle, + }); + + await useChatStore.getState().refreshTopic(); + + return { + cached: { + avatar: meta.avatar, + backgroundColor: meta.backgroundColor, + title: defaultTitle, + }, + url: `/agent/${agentId}/${topicId}`, + }; + }, + }; + }, + icon: MessageSquare, + titleKey: 'navigation.chat', + useDynamicMeta: (params): DynamicRouteMeta => { + const meta = useAgentStore(agentSelectors.getAgentMetaById(params.aid ?? '')); + const topicTitle = useTopicTitle(params.topicId); + const hasMeta = Object.keys(meta).length > 0; + const agentTitle = hasMeta ? meta.title : undefined; + + return { + avatar: meta.avatar, + backgroundColor: meta.backgroundColor, + title: [topicTitle, agentTitle].filter(Boolean).join(' · ') || undefined, + }; + }, +}); diff --git a/src/routes/(main)/agent/features/topicPageRouteMeta.ts b/src/routes/(main)/agent/features/topicPageRouteMeta.ts new file mode 100644 index 0000000000..36bfdc186e --- /dev/null +++ b/src/routes/(main)/agent/features/topicPageRouteMeta.ts @@ -0,0 +1,32 @@ +import { FilePenIcon } from 'lucide-react'; + +import { type DynamicRouteMeta, routeMeta } from '@/spa/router/routeMeta'; +import { useAgentStore } from '@/store/agent'; +import { agentSelectors } from '@/store/agent/selectors'; +import { useChatStore } from '@/store/chat'; + +const useTopicTitle = (topicId: string | undefined): string | undefined => + useChatStore((s) => { + if (!topicId) return undefined; + for (const data of Object.values(s.topicDataMap)) { + const topic = data.items?.find((item) => item.id === topicId); + if (topic?.title) return topic.title; + } + return undefined; + }); + +export const agentTopicPageRouteMeta = routeMeta({ + icon: FilePenIcon, + titleKey: 'navigation.page', + useDynamicMeta: (params): DynamicRouteMeta => { + const meta = useAgentStore(agentSelectors.getAgentMetaById(params.aid ?? '')); + const topicTitle = useTopicTitle(params.topicId); + const hasMeta = Object.keys(meta).length > 0; + + return { + avatar: meta.avatar, + backgroundColor: meta.backgroundColor, + title: topicTitle || (hasMeta ? meta.title : undefined), + }; + }, +}); diff --git a/src/routes/(main)/agent/index.desktop.test.tsx b/src/routes/(main)/agent/index.desktop.test.tsx index e9d5f57c22..20431083cc 100644 --- a/src/routes/(main)/agent/index.desktop.test.tsx +++ b/src/routes/(main)/agent/index.desktop.test.tsx @@ -66,10 +66,6 @@ vi.mock('./features/Conversation/WorkingSidebar', () => ({ default: () =>
, })); -vi.mock('./features/PageTitle', () => ({ - default: () =>
, -})); - vi.mock('./features/Portal', () => ({ default: () =>
, })); diff --git a/src/routes/(main)/agent/index.desktop.tsx b/src/routes/(main)/agent/index.desktop.tsx index 172133f787..5f1ae49e11 100644 --- a/src/routes/(main)/agent/index.desktop.tsx +++ b/src/routes/(main)/agent/index.desktop.tsx @@ -10,7 +10,6 @@ import { useChatStore } from '@/store/chat'; import Conversation from './features/Conversation'; import ChatHydration from './features/Conversation/ChatHydration'; -import PageTitle from './features/PageTitle'; import TelemetryNotification from './features/TelemetryNotification'; const ChatPage = memo(() => { @@ -26,13 +25,9 @@ const ChatPage = memo(() => { // to the popup instead. const pageContent = urlTopicId && popup ? ( - <> - - - + ) : ( <> - { return ( <> - (({ id, title, fav, active, threadId, stat toggleMobileTopic(false); return; } - const reference = pluginRegistry.parseUrl(`/group/${activeGroupId}`, `topic=${id}`); - if (reference) { - addTab(reference); - switchTopic(id); - toggleMobileTopic(false); - } + addTab(`/group/${activeGroupId}?topic=${id}`); + switchTopic(id); + toggleMobileTopic(false); }, [id, activeGroupId, addTab, focusTopicPopup, switchTopic, toggleMobileTopic]); const dropdownMenu = useTopicItemDropdownMenu({ diff --git a/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx b/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx index d888cb4b76..97c277893a 100644 --- a/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx +++ b/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx @@ -19,7 +19,6 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { isDesktop } from '@/const/version'; -import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins'; import { useAppOrigin } from '@/hooks/useAppOrigin'; import { useAgentGroupStore } from '@/store/agentGroup'; import { useChatStore } from '@/store/chat'; @@ -109,11 +108,8 @@ export const useTopicItemDropdownMenu = ({ onClick: () => { if (!activeGroupId) return; const url = `/group/${activeGroupId}?topic=${id}`; - const reference = pluginRegistry.parseUrl(`/group/${activeGroupId}`, `topic=${id}`); - if (reference) { - addTab(reference); - navigate(url); - } + addTab(url); + navigate(url); }, }, { diff --git a/src/routes/(main)/group/features/PageTitle/index.tsx b/src/routes/(main)/group/features/PageTitle/index.tsx deleted file mode 100644 index 60e97a1d17..0000000000 --- a/src/routes/(main)/group/features/PageTitle/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -'use client'; - -import { memo } from 'react'; - -import PageTitle from '@/components/PageTitle'; -import { useAgentStore } from '@/store/agent'; -import { agentSelectors } from '@/store/agent/selectors'; -import { useChatStore } from '@/store/chat'; -import { topicSelectors } from '@/store/chat/selectors'; - -const Title = memo(() => { - const agentTitle = useAgentStore(agentSelectors.currentAgentTitle); - - const topicTitle = useChatStore((s) => topicSelectors.currentActiveTopic(s)?.title); - return ; -}); - -export default Title; diff --git a/src/routes/(main)/group/features/routeMeta.ts b/src/routes/(main)/group/features/routeMeta.ts new file mode 100644 index 0000000000..e886362c73 --- /dev/null +++ b/src/routes/(main)/group/features/routeMeta.ts @@ -0,0 +1,58 @@ +import { t } from 'i18next'; +import { Users } from 'lucide-react'; +import { useSearchParams } from 'react-router-dom'; + +import { lambdaClient } from '@/libs/trpc/client'; +import { type DynamicRouteMeta, routeMeta } from '@/spa/router/routeMeta'; +import { useChatStore } from '@/store/chat'; +import { useSessionStore } from '@/store/session'; +import { sessionGroupSelectors } from '@/store/session/slices/sessionGroup/selectors'; + +const useTopicTitle = (topicId: string | null): string | undefined => + useChatStore((s) => { + if (!topicId) return undefined; + for (const data of Object.values(s.topicDataMap)) { + const topic = data.items?.find((item) => item.id === topicId); + if (topic?.title) return topic.title; + } + return undefined; + }); + +export const groupRouteMeta = routeMeta({ + createNewTab: (params) => { + const groupId = params.gid; + if (!groupId) return null; + + return { + onCreate: async () => { + const group = sessionGroupSelectors.getGroupById(groupId)(useSessionStore.getState()); + if (!group) return null; + + const defaultTitle = t('defaultTitle', { ns: 'topic' }); + const topicId = await lambdaClient.topic.createTopic.mutate({ + groupId, + messages: [], + title: defaultTitle, + }); + + await useChatStore.getState().refreshTopic(); + + return { + cached: { title: defaultTitle }, + url: `/group/${groupId}?topic=${topicId}`, + }; + }, + }; + }, + icon: Users, + titleKey: 'navigation.groupChat', + useDynamicMeta: (params): DynamicRouteMeta => { + const [searchParams] = useSearchParams(); + const group = useSessionStore(sessionGroupSelectors.getGroupById(params.gid ?? '')); + const topicTitle = useTopicTitle(searchParams.get('topic')); + + return { + title: topicTitle || group?.name || undefined, + }; + }, +}); diff --git a/src/routes/(main)/group/index.desktop.tsx b/src/routes/(main)/group/index.desktop.tsx index 8ac28a9b5b..b7082ac49a 100644 --- a/src/routes/(main)/group/index.desktop.tsx +++ b/src/routes/(main)/group/index.desktop.tsx @@ -9,7 +9,6 @@ import { useChatStore } from '@/store/chat'; import Conversation from './features/Conversation'; import ChatHydration from './features/Conversation/ChatHydration'; -import PageTitle from './features/PageTitle'; import Portal from './features/Portal'; import TelemetryNotification from './features/TelemetryNotification'; @@ -25,7 +24,6 @@ const ChatPage = memo(() => { return ( <> - ); @@ -33,7 +31,6 @@ const ChatPage = memo(() => { return ( <> - { return ( <> - { - const { pathname } = useLocation(); - const isHomeRoute = pathname === '/'; - return ( <> - {isHomeRoute && } { const storeUpdater = createStoreUpdater(usePageStore); const params = useParams<{ id: string }>(); @@ -22,18 +17,14 @@ const PagesPage = memo(() => { const pageId = getIdFromIdentifier(params.id ?? '', 'docs'); storeUpdater('selectedPageId', pageId); - // Clear activeAgentId when unmounting (leaving chat page) useUnmount(() => { usePageStore.setState({ selectedPageId: undefined }); }); return ( - <> - - }> - - - + }> + + ); }); diff --git a/src/routes/(main)/page/index.tsx b/src/routes/(main)/page/index.tsx index b626e7b121..a7d345abe3 100644 --- a/src/routes/(main)/page/index.tsx +++ b/src/routes/(main)/page/index.tsx @@ -4,20 +4,12 @@ import { memo, Suspense } from 'react'; import Loading from '@/components/Loading/BrandTextLoading'; import PageExplorerPlaceholder from '@/features/PageExplorer/PageExplorerPlaceholder'; -import { PageTitle } from '@/features/Pages'; -/** - * Pages route - dedicated page for managing documents/pages - * This is extracted from the /resource route to have its own dedicated space - */ const PagesPage = memo(() => { return ( - <> - - }> - - - + }> + + ); }); diff --git a/src/routes/(main)/settings/features/routeMeta.ts b/src/routes/(main)/settings/features/routeMeta.ts new file mode 100644 index 0000000000..ccdaca2179 --- /dev/null +++ b/src/routes/(main)/settings/features/routeMeta.ts @@ -0,0 +1,25 @@ +import { Settings } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { type DynamicRouteMeta, routeMeta } from '@/spa/router/routeMeta'; +import { SettingsTabs } from '@/store/global/initialState'; + +import { useCategory } from '../hooks/useCategory'; + +export const settingsRouteMeta = routeMeta({ + icon: Settings, + titleKey: 'navigation.settings', + useDynamicMeta: (params): DynamicRouteMeta => { + const { t: tAuth } = useTranslation('auth'); + const groups = useCategory(); + const activeTab = (params.tab as SettingsTabs) || SettingsTabs.Profile; + + if (activeTab === SettingsTabs.Profile) return { title: tAuth('tab.profile') }; + + const label = groups + .flatMap((group) => group.items) + .find((item) => item.key === activeTab)?.label; + + return { title: label || undefined }; + }, +}); diff --git a/src/routes/(mobile)/_layout/index.tsx b/src/routes/(mobile)/_layout/index.tsx index ea4ea82574..e3ce6bbcab 100644 --- a/src/routes/(mobile)/_layout/index.tsx +++ b/src/routes/(mobile)/_layout/index.tsx @@ -5,6 +5,7 @@ import { Suspense } from 'react'; import { Outlet, useLocation } from 'react-router-dom'; import Loading from '@/components/Loading/BrandTextLoading'; +import { RouteMetaBridge } from '@/features/RouteMeta'; import { MarketAuthProvider } from '@/layout/AuthProvider/MarketAuth'; import dynamic from '@/libs/next/dynamic'; import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig'; @@ -30,6 +31,7 @@ const MobileMainLayout: FC = () => { const showNav = MOBILE_NAV_ROUTES.has(pathname); return ( <> + {showCloudPromotion && } }> diff --git a/src/routes/(mobile)/chat/index.tsx b/src/routes/(mobile)/chat/index.tsx index ebaae0f8c1..643de605b3 100644 --- a/src/routes/(mobile)/chat/index.tsx +++ b/src/routes/(mobile)/chat/index.tsx @@ -4,7 +4,6 @@ import { memo } from 'react'; import ChatHydration from '@/routes/(main)/agent/features/Conversation/ChatHydration'; import ConversationArea from '@/routes/(main)/agent/features/Conversation/ConversationArea'; -import PageTitle from '@/routes/(main)/agent/features/PageTitle'; import PortalPanel from '@/routes/(main)/agent/features/Portal/features/PortalPanel'; import TelemetryNotification from '@/routes/(main)/agent/features/TelemetryNotification'; @@ -14,7 +13,6 @@ const MobileChatPage = memo(() => { return ( <> - diff --git a/src/routes/(mobile)/chat/settings/index.tsx b/src/routes/(mobile)/chat/settings/index.tsx index 56c89a0a8b..1491a10557 100644 --- a/src/routes/(mobile)/chat/settings/index.tsx +++ b/src/routes/(mobile)/chat/settings/index.tsx @@ -3,9 +3,7 @@ import { Tabs } from '@lobehub/ui'; import { cssVar } from 'antd-style'; import { memo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import PageTitle from '@/components/PageTitle'; import MobileContentLayout from '@/components/server/MobileNavLayout'; import { useCategory } from '@/features/AgentSetting/AgentCategory/useCategory'; import AgentSettings from '@/features/AgentSetting/AgentSettings'; @@ -17,24 +15,21 @@ import { ChatSettingsTabs } from '@/store/global/initialState'; import { useSessionStore } from '@/store/session'; export default memo(() => { - const { t } = useTranslation('setting'); const [tab, setTab] = useState(ChatSettingsTabs.Prompt); const cateItems = useCategory(); const id = useSessionStore((s) => s.activeId); - const [updateAgentConfig, updateAgentMeta, config, meta, title] = useAgentStore((s) => [ + const [updateAgentConfig, updateAgentMeta, config, meta] = useAgentStore((s) => [ s.updateAgentConfig, s.updateAgentMeta, agentSelectors.currentAgentConfig(s), agentSelectors.currentAgentMeta(s), - agentSelectors.currentAgentTitle(s), ]); const isLoading = false; return ( }> - { return ( data?.title && ( - <> - - - {data.title} - - + + {data.title} + ) ); }); diff --git a/src/routes/share/t/[id]/_layout/index.tsx b/src/routes/share/t/[id]/_layout/index.tsx index 80bb1e6f49..8a24fdb895 100644 --- a/src/routes/share/t/[id]/_layout/index.tsx +++ b/src/routes/share/t/[id]/_layout/index.tsx @@ -10,6 +10,7 @@ import { Link, Outlet } from 'react-router-dom'; import { ProductLogo } from '@/components/Branding'; import Loading from '@/components/Loading/BrandTextLoading'; +import { RouteMetaBridge } from '@/features/RouteMeta'; import { trackLoginOrSignupClicked } from '@/features/User/UserLoginOrSignup/trackLoginOrSignupClicked'; import { useIsDark } from '@/hooks/useIsDark'; import { useUserStore } from '@/store/user'; @@ -26,6 +27,7 @@ const ShareTopicLayout = memo(({ children }) => { return ( + , + handle: { meta: agentRouteMeta }, index: true, }, { children: [ { element: , + handle: { meta: agentRouteMeta }, index: true, }, { children: [ { element: , + handle: { meta: agentTopicPageRouteMeta }, index: true, }, { element: , + handle: { meta: agentTopicPageRouteMeta }, path: ':docId', }, ], @@ -141,6 +161,7 @@ export const desktopRoutes: RouteObject[] = [ }, { element: , + handle: { meta: taskRouteMeta }, path: 'task/:taskId', }, ], @@ -163,6 +184,7 @@ export const desktopRoutes: RouteObject[] = [ children: [ { element: , + handle: { meta: groupRouteMeta }, index: true, }, { @@ -188,6 +210,12 @@ export const desktopRoutes: RouteObject[] = [ children: [ { element: , + handle: { + meta: routeMeta({ + icon: ShapesIcon, + titleKey: 'navigation.discoverAssistants', + }), + }, index: true, }, ], @@ -198,6 +226,9 @@ export const desktopRoutes: RouteObject[] = [ children: [ { element: , + handle: { + meta: routeMeta({ icon: ShapesIcon, titleKey: 'navigation.discoverModels' }), + }, index: true, }, ], @@ -206,12 +237,18 @@ export const desktopRoutes: RouteObject[] = [ }, { element: , + handle: { + meta: routeMeta({ icon: ShapesIcon, titleKey: 'navigation.discoverProviders' }), + }, path: 'provider', }, { children: [ { element: , + handle: { + meta: routeMeta({ icon: ShapesIcon, titleKey: 'navigation.discover' }), + }, index: true, }, ], @@ -222,6 +259,9 @@ export const desktopRoutes: RouteObject[] = [ children: [ { element: , + handle: { + meta: routeMeta({ icon: ShapesIcon, titleKey: 'navigation.discoverMcp' }), + }, index: true, }, ], @@ -230,6 +270,9 @@ export const desktopRoutes: RouteObject[] = [ }, { element: , + handle: { + meta: routeMeta({ icon: ShapesIcon, titleKey: 'navigation.discover' }), + }, index: true, }, ], @@ -283,6 +326,9 @@ export const desktopRoutes: RouteObject[] = [ children: [ { element: , + handle: { + meta: routeMeta({ icon: LibraryBigIcon, titleKey: 'navigation.resources' }), + }, index: true, }, ], @@ -293,10 +339,16 @@ export const desktopRoutes: RouteObject[] = [ children: [ { element: , + handle: { + meta: routeMeta({ icon: LibraryBigIcon, titleKey: 'navigation.knowledgeBase' }), + }, index: true, }, { element: , + handle: { + meta: routeMeta({ icon: LibraryBigIcon, titleKey: 'navigation.knowledgeBase' }), + }, path: ':slug', }, ], @@ -325,21 +377,29 @@ export const desktopRoutes: RouteObject[] = [ }, { element: , + handle: { + meta: routeMeta({ icon: Settings, titleKey: 'navigation.provider' }), + }, path: ':providerId', }, ], element: , + handle: { + meta: routeMeta({ icon: Settings, titleKey: 'navigation.provider' }), + }, path: 'provider', }, // Other settings tabs { element: , + handle: { meta: settingsRouteMeta }, path: ':tab', }, // Tabs that need a sub-segment (e.g. /settings/messenger/discord) reuse // the same tab page; nested feature components read `:sub` via useParams. { element: , + handle: { meta: settingsRouteMeta }, path: ':tab/:sub', }, ], @@ -353,26 +413,44 @@ export const desktopRoutes: RouteObject[] = [ children: [ { element: , + handle: { + meta: routeMeta({ icon: BrainCircuit, titleKey: 'navigation.memory' }), + }, index: true, }, { element: , + handle: { + meta: routeMeta({ icon: BrainCircuit, titleKey: 'navigation.memoryIdentities' }), + }, path: 'identities', }, { element: , + handle: { + meta: routeMeta({ icon: BrainCircuit, titleKey: 'navigation.memoryContexts' }), + }, path: 'contexts', }, { element: , + handle: { + meta: routeMeta({ icon: BrainCircuit, titleKey: 'navigation.memoryPreferences' }), + }, path: 'preferences', }, { element: , + handle: { + meta: routeMeta({ icon: BrainCircuit, titleKey: 'navigation.memoryExperiences' }), + }, path: 'experiences', }, { element: , + handle: { + meta: routeMeta({ icon: BrainCircuit, titleKey: 'navigation.memory' }), + }, path: 'activities', }, ], @@ -399,6 +477,9 @@ export const desktopRoutes: RouteObject[] = [ children: [ { element: , + handle: { + meta: routeMeta({ icon: Image, titleKey: 'navigation.image' }), + }, index: true, }, ], @@ -463,6 +544,7 @@ export const desktopRoutes: RouteObject[] = [ children: [ { element: , + handle: { meta: tasksRouteMeta }, index: true, }, ], @@ -473,6 +555,7 @@ export const desktopRoutes: RouteObject[] = [ children: [ { element: , + handle: { meta: taskRouteMeta }, path: ':taskId', }, ], @@ -488,10 +571,14 @@ export const desktopRoutes: RouteObject[] = [ children: [ { element: , + handle: { + meta: routeMeta({ icon: FilePenIcon, titleKey: 'navigation.pages' }), + }, index: true, }, { element: , + handle: { meta: pageRouteMeta }, path: ':id', }, ], @@ -502,6 +589,9 @@ export const desktopRoutes: RouteObject[] = [ // Default route - home page (handled by persistent layout) { + handle: { + meta: routeMeta({ icon: Home, titleKey: 'navigation.home' }), + }, index: true, }, // Catch-all route diff --git a/src/spa/router/desktopRouter.config.tsx b/src/spa/router/desktopRouter.config.tsx index 0be1c002d1..dbdc6b5adf 100644 --- a/src/spa/router/desktopRouter.config.tsx +++ b/src/spa/router/desktopRouter.config.tsx @@ -1,11 +1,27 @@ 'use client'; +import { + BrainCircuit, + FilePenIcon, + Home, + Image, + LibraryBigIcon, + Settings, + ShapesIcon, +} from 'lucide-react'; import { type RouteObject } from 'react-router-dom'; import { BusinessDesktopRoutesWithMainLayout, BusinessDesktopRoutesWithoutMainLayout, } from '@/business/client/BusinessDesktopRoutes'; +import { taskRouteMeta, tasksRouteMeta } from '@/features/AgentTasks/routeMeta'; +import { pageRouteMeta } from '@/features/Pages/routeMeta'; +import { agentRouteMeta } from '@/routes/(main)/agent/features/routeMeta'; +import { agentTopicPageRouteMeta } from '@/routes/(main)/agent/features/topicPageRouteMeta'; +import { groupRouteMeta } from '@/routes/(main)/group/features/routeMeta'; +import { settingsRouteMeta } from '@/routes/(main)/settings/features/routeMeta'; +import { routeMeta } from '@/spa/router/routeMeta'; import { dynamicElement, dynamicLayout, ErrorBoundary, redirectElement } from '@/utils/router'; const agentChatElement = dynamicElement(() => import('@/routes/(main)/agent'), 'Desktop > Chat'); @@ -27,12 +43,14 @@ export const desktopRoutes: RouteObject[] = [ children: [ { element: agentChatElement, + handle: { meta: agentRouteMeta }, index: true, }, { children: [ { element: agentChatElement, + handle: { meta: agentRouteMeta }, index: true, }, { @@ -42,6 +60,7 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/agent/[topicId]/page'), 'Desktop > Chat > Topic > Page > Redirect', ), + handle: { meta: agentTopicPageRouteMeta }, index: true, }, { @@ -49,6 +68,7 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/agent/[topicId]/page/[docId]'), 'Desktop > Chat > Topic > Page > Doc', ), + handle: { meta: agentTopicPageRouteMeta }, path: ':docId', }, ], @@ -89,6 +109,7 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/agent/task/[taskId]'), 'Desktop > Chat > Task Detail', ), + handle: { meta: taskRouteMeta }, path: 'task/:taskId', }, ], @@ -117,6 +138,7 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/group'), 'Desktop > Agent Group', ), + handle: { meta: groupRouteMeta }, index: true, }, { @@ -151,6 +173,12 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/community/(list)/agent'), 'Desktop > Discover > List > Agent', ), + handle: { + meta: routeMeta({ + icon: ShapesIcon, + titleKey: 'navigation.discoverAssistants', + }), + }, index: true, }, ], @@ -167,6 +195,9 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/community/(list)/model'), 'Desktop > Discover > List > Model', ), + handle: { + meta: routeMeta({ icon: ShapesIcon, titleKey: 'navigation.discoverModels' }), + }, index: true, }, ], @@ -181,6 +212,9 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/community/(list)/provider'), 'Desktop > Discover > List > Provider', ), + handle: { + meta: routeMeta({ icon: ShapesIcon, titleKey: 'navigation.discoverProviders' }), + }, path: 'provider', }, { @@ -190,6 +224,9 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/community/(list)/skill'), 'Desktop > Discover > List > Skill', ), + handle: { + meta: routeMeta({ icon: ShapesIcon, titleKey: 'navigation.discover' }), + }, index: true, }, ], @@ -206,6 +243,9 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/community/(list)/mcp'), 'Desktop > Discover > List > MCP', ), + handle: { + meta: routeMeta({ icon: ShapesIcon, titleKey: 'navigation.discoverMcp' }), + }, index: true, }, ], @@ -220,6 +260,9 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/community/(list)/(home)'), 'Desktop > Discover > List > Home', ), + handle: { + meta: routeMeta({ icon: ShapesIcon, titleKey: 'navigation.discover' }), + }, index: true, }, ], @@ -306,6 +349,9 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/resource/(home)'), 'Desktop > Resource > Home', ), + handle: { + meta: routeMeta({ icon: LibraryBigIcon, titleKey: 'navigation.resources' }), + }, index: true, }, ], @@ -322,6 +368,9 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/resource/library'), 'Desktop > Resource > Library', ), + handle: { + meta: routeMeta({ icon: LibraryBigIcon, titleKey: 'navigation.knowledgeBase' }), + }, index: true, }, { @@ -329,6 +378,9 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/resource/library/[slug]'), 'Desktop > Resource > Library > Slug', ), + handle: { + meta: routeMeta({ icon: LibraryBigIcon, titleKey: 'navigation.knowledgeBase' }), + }, path: ':slug', }, ], @@ -367,6 +419,9 @@ export const desktopRoutes: RouteObject[] = [ import('@/routes/(main)/settings/provider').then((m) => m.ProviderDetailPage), 'Desktop > Settings > Provider > Detail', ), + handle: { + meta: routeMeta({ icon: Settings, titleKey: 'navigation.provider' }), + }, path: ':providerId', }, ], @@ -374,6 +429,9 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/settings/provider').then((m) => m.ProviderLayout), 'Desktop > Settings > Provider > Layout', ), + handle: { + meta: routeMeta({ icon: Settings, titleKey: 'navigation.provider' }), + }, path: 'provider', }, // Other settings tabs @@ -382,6 +440,7 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/settings'), 'Desktop > Settings > Tab', ), + handle: { meta: settingsRouteMeta }, path: ':tab', }, // Tabs that need a sub-segment (e.g. /settings/messenger/discord) reuse @@ -391,6 +450,7 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/settings'), 'Desktop > Settings > Tab > Sub', ), + handle: { meta: settingsRouteMeta }, path: ':tab/:sub', }, ], @@ -410,6 +470,9 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/memory/(home)'), 'Desktop > Memory > Home', ), + handle: { + meta: routeMeta({ icon: BrainCircuit, titleKey: 'navigation.memory' }), + }, index: true, }, { @@ -417,6 +480,9 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/memory/identities'), 'Desktop > Memory > Identities', ), + handle: { + meta: routeMeta({ icon: BrainCircuit, titleKey: 'navigation.memoryIdentities' }), + }, path: 'identities', }, { @@ -424,6 +490,9 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/memory/contexts'), 'Desktop > Memory > Contexts', ), + handle: { + meta: routeMeta({ icon: BrainCircuit, titleKey: 'navigation.memoryContexts' }), + }, path: 'contexts', }, { @@ -431,6 +500,9 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/memory/preferences'), 'Desktop > Memory > Preferences', ), + handle: { + meta: routeMeta({ icon: BrainCircuit, titleKey: 'navigation.memoryPreferences' }), + }, path: 'preferences', }, { @@ -438,6 +510,9 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/memory/experiences'), 'Desktop > Memory > Experiences', ), + handle: { + meta: routeMeta({ icon: BrainCircuit, titleKey: 'navigation.memoryExperiences' }), + }, path: 'experiences', }, { @@ -445,6 +520,9 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/memory/activities'), 'Desktop > Memory > Activities', ), + handle: { + meta: routeMeta({ icon: BrainCircuit, titleKey: 'navigation.memory' }), + }, path: 'activities', }, ], @@ -483,6 +561,9 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/(create)/image'), 'Desktop > Image', ), + handle: { + meta: routeMeta({ icon: Image, titleKey: 'navigation.image' }), + }, index: true, }, ], @@ -575,6 +656,7 @@ export const desktopRoutes: RouteObject[] = [ children: [ { element: dynamicElement(() => import('@/routes/(main)/tasks'), 'Desktop > Tasks'), + handle: { meta: tasksRouteMeta }, index: true, }, ], @@ -588,6 +670,7 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/task/[taskId]'), 'Desktop > Task Detail', ), + handle: { meta: taskRouteMeta }, path: ':taskId', }, ], @@ -606,6 +689,9 @@ export const desktopRoutes: RouteObject[] = [ children: [ { element: dynamicElement(() => import('@/routes/(main)/page'), 'Desktop > Page'), + handle: { + meta: routeMeta({ icon: FilePenIcon, titleKey: 'navigation.pages' }), + }, index: true, }, { @@ -613,6 +699,7 @@ export const desktopRoutes: RouteObject[] = [ () => import('@/routes/(main)/page/[id]'), 'Desktop > Page > Detail', ), + handle: { meta: pageRouteMeta }, path: ':id', }, ], @@ -626,6 +713,9 @@ export const desktopRoutes: RouteObject[] = [ // Default route - home page (handled by persistent layout) { + handle: { + meta: routeMeta({ icon: Home, titleKey: 'navigation.home' }), + }, index: true, }, // Catch-all route diff --git a/src/spa/router/desktopRouter.sync.test.tsx b/src/spa/router/desktopRouter.sync.test.tsx index 6e33fd840a..8e3fbe034c 100644 --- a/src/spa/router/desktopRouter.sync.test.tsx +++ b/src/spa/router/desktopRouter.sync.test.tsx @@ -15,6 +15,31 @@ function extractIndexCount(source: string) { return [...source.matchAll(/index:\s*true/g)].length; } +function extractHandleMetas(source: string) { + const metas: string[] = []; + const marker = 'handle:'; + + let cursor = source.indexOf(marker); + while (cursor !== -1) { + const braceStart = source.indexOf('{', cursor + marker.length); + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const char = source[end]; + if (char === '{') depth += 1; + else if (char === '}') { + depth -= 1; + if (depth === 0) break; + } + } + + metas.push(source.slice(braceStart, end + 1).replaceAll(/\s+/g, ' ')); + cursor = source.indexOf(marker, end + 1); + } + + return metas.sort(); +} + function extractPaths(source: string) { return [...source.matchAll(/path:\s*'([^']+)'/g)].map((match) => match[1]); } @@ -49,6 +74,20 @@ describe('desktopRouter config sync', () => { ); }); + it('route handle.meta declarations must match between web and desktop configs', async () => { + const [asyncSource, syncSource] = await readDesktopRouterSources(); + + const asyncMetas = extractHandleMetas(asyncSource); + const syncMetas = extractHandleMetas(syncSource); + + expect(asyncMetas.length, 'Async config must declare at least one handle.meta').toBeGreaterThan( + 0, + ); + expect(syncMetas, 'Desktop config handle.meta declarations must match async config').toEqual( + asyncMetas, + ); + }); + it('task list and detail desktop routes share one workspace layout', async () => { const [asyncSource, syncSource] = await readDesktopRouterSources(); diff --git a/src/spa/router/mobileRouter.config.tsx b/src/spa/router/mobileRouter.config.tsx index 3a8e33ce5b..2ccac8a6e8 100644 --- a/src/spa/router/mobileRouter.config.tsx +++ b/src/spa/router/mobileRouter.config.tsx @@ -1,11 +1,16 @@ 'use client'; -import { type RouteObject } from 'react-router-dom'; +import type { RouteObject } from 'react-router-dom'; import { BusinessMobileRoutesWithMainLayout, BusinessMobileRoutesWithoutMainLayout, } from '@/business/client/BusinessMobileRoutes'; +import { + mobileAgentSettingsRouteMeta, + shareTopicRouteMeta, +} from '@/features/RouteMeta/mobileRouteMeta'; +import { agentRouteMeta } from '@/routes/(main)/agent/features/routeMeta'; import { dynamicElement, dynamicLayout, ErrorBoundary, redirectElement } from '@/utils/router'; // Mobile router configuration (declarative mode) @@ -23,6 +28,7 @@ export const mobileRoutes: RouteObject[] = [ children: [ { element: dynamicElement(() => import('@/routes/(mobile)/chat'), 'Mobile > Chat'), + handle: { meta: agentRouteMeta }, index: true, }, { @@ -30,6 +36,7 @@ export const mobileRoutes: RouteObject[] = [ () => import('@/routes/(mobile)/chat'), 'Mobile > Chat > Topic', ), + handle: { meta: agentRouteMeta }, path: ':topicId', }, { @@ -37,6 +44,7 @@ export const mobileRoutes: RouteObject[] = [ () => import('@/routes/(mobile)/chat/settings'), 'Mobile > Chat > Settings', ), + handle: { meta: mobileAgentSettingsRouteMeta }, path: 'settings', }, ], @@ -379,6 +387,7 @@ export const mobileRoutes: RouteObject[] = [ children: [ { element: dynamicElement(() => import('@/routes/share/t/[id]'), 'Mobile > Share > Topic'), + handle: { meta: shareTopicRouteMeta }, path: ':id', }, ], diff --git a/src/spa/router/routeMeta.ts b/src/spa/router/routeMeta.ts new file mode 100644 index 0000000000..7a7f716638 --- /dev/null +++ b/src/spa/router/routeMeta.ts @@ -0,0 +1,44 @@ +import { type LucideIcon } from 'lucide-react'; + +export interface StaticRouteMeta { + icon?: LucideIcon; + titleKey?: string; +} + +export interface DynamicRouteMeta { + avatar?: string; + backgroundColor?: string; + title?: string; +} + +export interface NewTabActionResult { + cached?: DynamicRouteMeta; + url: string; +} + +export interface NewTabAction { + onCreate: () => Promise; +} + +export interface RouteMeta extends StaticRouteMeta { + createNewTab?: (params: Record) => NewTabAction | null; + useDynamicMeta?: (params: Record) => DynamicRouteMeta; +} + +export interface RouteHandle { + meta?: RouteMeta; +} + +export interface ResolvedRouteMeta { + avatar?: string; + backgroundColor?: string; + icon?: LucideIcon; + title: string; +} + +export const routeMeta = (meta: RouteMeta): RouteMeta => meta; + +export const getRouteMetaFromHandle = (handle: unknown): RouteMeta | undefined => { + if (!handle || typeof handle !== 'object') return undefined; + return (handle as RouteHandle).meta; +}; diff --git a/src/store/electron/actions/__tests__/tabPages.test.ts b/src/store/electron/actions/__tests__/tabPages.test.ts index 30cd99bdf2..1de075e84a 100644 --- a/src/store/electron/actions/__tests__/tabPages.test.ts +++ b/src/store/electron/actions/__tests__/tabPages.test.ts @@ -1,27 +1,15 @@ import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { type PageReference } from '@/features/Electron/titlebar/RecentlyViewed/types'; +import { type TabItem } from '@/features/Electron/titlebar/TabBar/types'; import { useElectronStore } from '@/store/electron'; import { initialState } from '@/store/electron/initialState'; -const buildAgentTab = (agentId = 'abc123'): PageReference => ({ - cached: { - avatar: 'avatar.png', - backgroundColor: '#fff', - title: 'Claude Code', - }, - id: `agent:${agentId}`, +const buildTab = (url: string, cached?: TabItem['cached']): TabItem => ({ + cached, + id: url, lastVisited: 1, - params: { agentId }, - type: 'agent', -}); - -const buildHomeReference = (): PageReference => ({ - id: 'home', - lastVisited: Date.now(), - params: {}, - type: 'home', + url, }); describe('tabPages actions', () => { @@ -34,121 +22,131 @@ describe('tabPages actions', () => { vi.restoreAllMocks(); }); + describe('addTab', () => { + it('uses the normalized URL as the tab id', () => { + const { result } = renderHook(() => useElectronStore()); + + act(() => { + result.current.addTab('/agent/abc?b=2&a=1'); + }); + + expect(result.current.tabs).toHaveLength(1); + expect(result.current.tabs[0].id).toBe('/agent/abc?a=1&b=2'); + expect(result.current.activeTabId).toBe('/agent/abc?a=1&b=2'); + }); + + it('dedupes tabs that resolve to the same normalized URL', () => { + const { result } = renderHook(() => useElectronStore()); + + act(() => { + result.current.addTab('/agent/abc?a=1&b=2'); + result.current.addTab('/agent/abc?b=2&a=1'); + }); + + expect(result.current.tabs).toHaveLength(1); + }); + + it('treats a trailing slash as the same identity', () => { + const { result } = renderHook(() => useElectronStore()); + + act(() => { + result.current.addTab('/agent/abc'); + result.current.addTab('/agent/abc/'); + }); + + expect(result.current.tabs).toHaveLength(1); + }); + }); + describe('updateTab', () => { - it('drops stale cached data when the tab switches to a different page type', () => { + it('drops cached data when the tab navigates to a different page', () => { const { result } = renderHook(() => useElectronStore()); - const agentTab = buildAgentTab(); + const agentTab = buildTab('/agent/abc', { title: 'Claude Code' }); act(() => { useElectronStore.setState({ activeTabId: agentTab.id, tabs: [agentTab] }); }); act(() => { - // Simulate navigating from an agent page to the home page. `useTabNavigation` - // passes no cached data for home, so the previous agent's cached title - // must not leak through. - result.current.updateTab(agentTab.id, buildHomeReference(), undefined); + result.current.updateTab(agentTab.id, '/'); }); const updatedTab = result.current.tabs[0]; - expect(updatedTab.type).toBe('home'); - expect(updatedTab.id).toBe('home'); + expect(updatedTab.id).toBe('/'); expect(updatedTab.cached).toBeUndefined(); - expect(result.current.activeTabId).toBe('home'); + expect(result.current.activeTabId).toBe('/'); }); - it('merges cached data when the tab type stays the same', () => { + it('keeps cached data when the normalized URL is unchanged', () => { const { result } = renderHook(() => useElectronStore()); - const agentTab = buildAgentTab('abc'); + const agentTab = buildTab('/agent/abc?a=1', { title: 'Claude Code' }); act(() => { useElectronStore.setState({ activeTabId: agentTab.id, tabs: [agentTab] }); }); act(() => { - result.current.updateTab( - agentTab.id, - { - id: 'agent:xyz', - lastVisited: Date.now(), - params: { agentId: 'xyz' }, - type: 'agent', - }, - { title: 'New Agent' }, - ); + result.current.updateTab(agentTab.id, '/agent/abc?a=1'); }); - const updatedTab = result.current.tabs[0]; - expect(updatedTab.id).toBe('agent:xyz'); - expect(updatedTab.cached).toEqual({ - avatar: 'avatar.png', - backgroundColor: '#fff', - title: 'New Agent', - }); - }); - - it('keeps previous cached data when same-type update passes undefined cached', () => { - const { result } = renderHook(() => useElectronStore()); - const agentTab = buildAgentTab('abc'); - - act(() => { - useElectronStore.setState({ activeTabId: agentTab.id, tabs: [agentTab] }); - }); - - act(() => { - result.current.updateTab( - agentTab.id, - { - id: 'agent:xyz', - lastVisited: Date.now(), - params: { agentId: 'xyz' }, - type: 'agent', - }, - undefined, - ); - }); - - expect(result.current.tabs[0].cached).toEqual(agentTab.cached); - }); - - it('overwrites cached data when switching to a different type, even if cached is provided', () => { - const { result } = renderHook(() => useElectronStore()); - const agentTab = buildAgentTab(); - - act(() => { - useElectronStore.setState({ activeTabId: agentTab.id, tabs: [agentTab] }); - }); - - act(() => { - result.current.updateTab( - agentTab.id, - { - id: 'group:g1', - lastVisited: Date.now(), - params: { groupId: 'g1' }, - type: 'group', - }, - { title: 'My Group' }, - ); - }); - - expect(result.current.tabs[0].cached).toEqual({ title: 'My Group' }); + expect(result.current.tabs[0].cached).toEqual({ title: 'Claude Code' }); }); it('does nothing when the tab id is not found', () => { const { result } = renderHook(() => useElectronStore()); - const agentTab = buildAgentTab(); + const agentTab = buildTab('/agent/abc'); act(() => { useElectronStore.setState({ activeTabId: agentTab.id, tabs: [agentTab] }); }); act(() => { - result.current.updateTab('non-existent', buildHomeReference()); + result.current.updateTab('non-existent', '/'); }); expect(result.current.tabs).toEqual([agentTab]); expect(result.current.activeTabId).toBe(agentTab.id); }); }); + + describe('updateTabCache (guarded merge)', () => { + it('skips undefined and empty-string fields, never clobbering a good value', () => { + const { result } = renderHook(() => useElectronStore()); + const agentTab = buildTab('/agent/abc', { + avatar: 'avatar.png', + title: 'Claude Code', + }); + + act(() => { + useElectronStore.setState({ activeTabId: agentTab.id, tabs: [agentTab] }); + }); + + act(() => { + result.current.updateTabCache(agentTab.id, { avatar: '', title: undefined }); + }); + + expect(result.current.tabs[0].cached).toEqual({ + avatar: 'avatar.png', + title: 'Claude Code', + }); + }); + + it('only writes defined non-empty fields', () => { + const { result } = renderHook(() => useElectronStore()); + const agentTab = buildTab('/agent/abc', { title: 'Old Title' }); + + act(() => { + useElectronStore.setState({ activeTabId: agentTab.id, tabs: [agentTab] }); + }); + + act(() => { + result.current.updateTabCache(agentTab.id, { avatar: 'a.png', title: 'New Title' }); + }); + + expect(result.current.tabs[0].cached).toEqual({ + avatar: 'a.png', + title: 'New Title', + }); + }); + }); }); diff --git a/src/store/electron/actions/navigationHistory.ts b/src/store/electron/actions/navigationHistory.ts index 4ff2703e61..a0d9b1dd19 100644 --- a/src/store/electron/actions/navigationHistory.ts +++ b/src/store/electron/actions/navigationHistory.ts @@ -1,3 +1,4 @@ +import { type DynamicRouteMeta } from '@/spa/router/routeMeta'; import { type StoreSetter } from '@/store/types'; import { type ElectronStore } from '../store'; @@ -17,10 +18,13 @@ export interface HistoryEntry { export interface NavigationHistoryState { /** - * Current page title from PageTitle component - * Used to get dynamic titles without setTimeout hack + * Live resolved meta of the active route, published by RouteMetaBridge */ - currentPageTitle: string; + currentRouteMeta: DynamicRouteMeta | null; + /** + * URL that produced currentRouteMeta. + */ + currentRouteMetaUrl: string | null; /** * Current position in history (-1 means empty) */ @@ -41,7 +45,8 @@ export interface NavigationHistoryState { // ======== Initial State ======== // export const navigationHistoryInitialState: NavigationHistoryState = { - currentPageTitle: '', + currentRouteMeta: null, + currentRouteMetaUrl: null, historyCurrentIndex: -1, historyEntries: [], isNavigatingHistory: false, @@ -198,8 +203,8 @@ export class NavigationHistoryActionImpl { ); }; - setCurrentPageTitle = (title: string): void => { - this.#set({ currentPageTitle: title }, false, 'setCurrentPageTitle'); + setCurrentRouteMeta = (meta: DynamicRouteMeta | null, url: string | null = null): void => { + this.#set({ currentRouteMeta: meta, currentRouteMetaUrl: url }, false, 'setCurrentRouteMeta'); }; setIsNavigatingHistory = (value: boolean): void => { diff --git a/src/store/electron/actions/recentPages.ts b/src/store/electron/actions/recentPages.ts index 83a3ba1204..da2e398229 100644 --- a/src/store/electron/actions/recentPages.ts +++ b/src/store/electron/actions/recentPages.ts @@ -2,10 +2,10 @@ import { getPinnedPages, savePinnedPages, } from '@/features/Electron/titlebar/RecentlyViewed/storage'; -import { - type CachedPageData, - type PageReference, -} from '@/features/Electron/titlebar/RecentlyViewed/types'; +import { guardedMergeCache } from '@/features/Electron/titlebar/TabBar/resolveRouteMeta'; +import { type TabItem } from '@/features/Electron/titlebar/TabBar/types'; +import { normalizeTabUrl } from '@/features/Electron/titlebar/TabBar/url'; +import { type DynamicRouteMeta } from '@/spa/router/routeMeta'; import { type StoreSetter } from '@/store/types'; import { type ElectronStore } from '../store'; @@ -18,12 +18,10 @@ const PINNED_PAGES_LIMIT = 10; // ======== Types ======== // export interface RecentPagesState { - pinnedPages: PageReference[]; - recentPages: PageReference[]; + pinnedPages: TabItem[]; + recentPages: TabItem[]; } -// ======== Action Interface ======== // - // ======== Initial State ======== // export const recentPagesInitialState: RecentPagesState = { @@ -47,44 +45,36 @@ export class RecentPagesActionImpl { this.#get = get; } - addRecentPage = (reference: PageReference, cached?: CachedPageData): void => { + addRecentPage = (url: string, cached?: DynamicRouteMeta): void => { const { pinnedPages, recentPages } = this.#get(); - const { id } = reference; + const id = normalizeTabUrl(url); - // 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 }, - }; - this.#set({ pinnedPages: updatedPinned }, false, 'updatePinnedPageCache'); - savePinnedPages(updatedPinned); - } + const merged = guardedMergeCache(pinnedPages[pinnedIndex].cached, cached); + if (merged === pinnedPages[pinnedIndex].cached) return; + + const updatedPinned = [...pinnedPages]; + updatedPinned[pinnedIndex] = { ...updatedPinned[pinnedIndex], cached: merged }; + this.#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, + const newEntry: TabItem = { + cached: guardedMergeCache(existingEntry?.cached, cached), + id, lastVisited: Date.now(), + url, 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); this.#set({ recentPages: newRecent }, false, 'addRecentPage'); @@ -103,35 +93,26 @@ export class RecentPagesActionImpl { const { recentPages } = this.#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)); this.#set({ pinnedPages: pinned, recentPages: filteredRecent }, false, 'loadPinnedPages'); }; - pinPage = (reference: PageReference): void => { + pinPage = (page: TabItem): void => { const { pinnedPages, recentPages } = this.#get(); - const { id } = reference; + const { id } = page; - // 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, + const newEntry: TabItem = { + ...page, + cached: page.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); @@ -151,8 +132,6 @@ export class RecentPagesActionImpl { 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); this.#set({ pinnedPages: newPinned, recentPages: newRecent }, false, 'unpinPage'); diff --git a/src/store/electron/actions/tabPages.ts b/src/store/electron/actions/tabPages.ts index 525f7cee42..a46c503b6d 100644 --- a/src/store/electron/actions/tabPages.ts +++ b/src/store/electron/actions/tabPages.ts @@ -1,8 +1,8 @@ -import { - type CachedPageData, - type PageReference, -} from '@/features/Electron/titlebar/RecentlyViewed/types'; +import { guardedMergeCache } from '@/features/Electron/titlebar/TabBar/resolveRouteMeta'; import { getTabPages, saveTabPages } from '@/features/Electron/titlebar/TabBar/storage'; +import { type TabItem } from '@/features/Electron/titlebar/TabBar/types'; +import { normalizeTabUrl } from '@/features/Electron/titlebar/TabBar/url'; +import { type DynamicRouteMeta } from '@/spa/router/routeMeta'; import { type StoreSetter } from '@/store/types'; import { type ElectronStore } from '../store'; @@ -11,7 +11,7 @@ import { type ElectronStore } from '../store'; export interface TabPagesState { activeTabId: string | null; - tabs: PageReference[]; + tabs: TabItem[]; } // ======== Initial State ======== // @@ -45,35 +45,37 @@ export class TabPagesActionImpl { this.#persist(); }; - addTab = (reference: PageReference, cached?: CachedPageData, activate = true): void => { + addTab = (url: string, cached?: DynamicRouteMeta, activate = true): string => { + const id = normalizeTabUrl(url); const { tabs } = this.#get(); - const existing = tabs.find((t) => t.id === reference.id); + const existing = tabs.find((t) => t.id === id); if (existing) { - // Tab already exists, just activate if (activate) { - this.#set({ activeTabId: existing.id }, false, 'activateExistingTab'); + this.#set({ activeTabId: id }, false, 'activateExistingTab'); this.#persist(); } - return; + return id; } - const newTab: PageReference = { - ...reference, + const newTab: TabItem = { cached, + id, lastVisited: Date.now(), + url, }; const newTabs = [...tabs, newTab]; this.#set( - { activeTabId: activate ? newTab.id : this.#get().activeTabId, tabs: newTabs }, + { activeTabId: activate ? id : this.#get().activeTabId, tabs: newTabs }, false, 'addTab', ); this.#persist(); + return id; }; - getActiveTab = (): PageReference | null => { + getActiveTab = (): TabItem | null => { const { activeTabId, tabs } = this.#get(); if (!activeTabId) return null; return tabs.find((t) => t.id === activeTabId) ?? null; @@ -154,41 +156,40 @@ export class TabPagesActionImpl { this.#persist(); }; - updateTab = (id: string, reference: PageReference, cached?: CachedPageData): void => { + updateTab = (id: string, url: string): string => { const { tabs, activeTabId } = this.#get(); const index = tabs.findIndex((t) => t.id === id); - if (index < 0) return; + if (index < 0) return id; + const nextId = normalizeTabUrl(url); const prev = tabs[index]; - // When the page type changes (e.g. agent -> home), the previous cached - // data (title/avatar) belongs to a different page and must not bleed - // through — otherwise the tab keeps showing the old page's title. - const sameType = prev.type === reference.type; const newTabs = [...tabs]; newTabs[index] = { - ...reference, - cached: sameType ? (cached ? { ...prev.cached, ...cached } : prev.cached) : cached, + ...prev, + cached: nextId === prev.id ? prev.cached : undefined, + id: nextId, lastVisited: Date.now(), + url, }; - // Keep activeTabId in sync when the updated tab was the active one - const newActiveTabId = activeTabId === id ? reference.id : activeTabId; + const newActiveTabId = activeTabId === id ? nextId : activeTabId; this.#set({ activeTabId: newActiveTabId, tabs: newTabs }, false, 'updateTab'); this.#persist(); + return nextId; }; - updateTabCache = (id: string, cached: CachedPageData): void => { + updateTabCache = (id: string, cached: DynamicRouteMeta): void => { const { tabs } = this.#get(); const index = tabs.findIndex((t) => t.id === id); if (index < 0) return; + const merged = guardedMergeCache(tabs[index].cached, cached); + if (merged === tabs[index].cached) return; + const newTabs = [...tabs]; - newTabs[index] = { - ...newTabs[index], - cached: { ...newTabs[index].cached, ...cached }, - }; + newTabs[index] = { ...newTabs[index], cached: merged }; this.#set({ tabs: newTabs }, false, 'updateTabCache'); this.#persist();