♻️ refactor(desktop): unify TabBar registration into a cross-platform route-meta layer (#14995)

* ♻️ refactor(desktop): unify TabBar registration into a cross-platform route-meta layer

Replace the desktop TabBar plugin registry with route-co-located metadata.

Previously four parallel registries (the RecentlyViewed plugin registry,
routeMetadata.ts, getRouteById icons, and the router config) had to be kept
in sync by hand; forgetting to register a page made its tab silently break.

Now every route declares its metadata once via `handle.meta`:
- New `routeMeta.ts` declaration types + a cross-platform `<RouteMetaBridge>`
  that resolves the active route's meta and drives `document.title`.
- Tab identity moves from semantic ids to normalized URLs (`TabItem`).
- Background-tab titles fall back through a guarded snapshot so cold-start
  store-data gaps never blank or clobber a tab.
- Deletes the 11 plugins, the registry, usePluginContext, routeMetadata.ts
  and cachedData.ts; `<PageTitle>` is removed from the (main) route tree.

*  feat(desktop): define route-meta title for task workspace routes

* ♻️ refactor(settings): create settingsRouteMeta for dynamic tab titles in settings

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

* ♻️ refactor(RouteMetaBridge): enhance dynamic route meta handling and state management

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

* 🐛 fix: scope route meta to tab url

* ♻️ refactor(PopupLayout): remove unused RouteMetaBridge component

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

* ♻️ refactor(route-meta): centralize web title updates

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-05-21 01:14:53 +08:00
committed by GitHub
parent c7976ce7f7
commit 7b7690fbb6
92 changed files with 1840 additions and 2815 deletions
+1
View File
@@ -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",
+1
View File
@@ -35,6 +35,7 @@
"navigation.recentView": "最近访问",
"navigation.resources": "资源",
"navigation.settings": "设置",
"navigation.task": "任务",
"navigation.tasks": "任务",
"navigation.unpin": "取消固定",
"notification.finishChatGeneration": "AI 消息已生成完毕",
-22
View File
@@ -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;
+22
View File
@@ -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,
};
},
});
@@ -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;
}
}
};
@@ -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;
};
@@ -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<string | null>(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();
});
@@ -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<string | null>(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]);
};
@@ -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);
@@ -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<PageItemProps>(({ 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<PageItemProps>(({ item, isPinned, onClose }) => {
gap={8}
onClick={handleClick}
>
{item.icon && <Icon className={styles.icon} icon={item.icon} size="small" />}
<span className={styles.itemTitle}>{item.title}</span>
{meta.icon && <Icon className={styles.icon} icon={meta.icon} size="small" />}
<span className={styles.itemTitle}>{meta.title}</span>
<ActionIcon
className={cx('actionIcon', styles.actionIcon)}
icon={isPinned ? PinOff : Pin}
@@ -2,13 +2,13 @@
import { memo } from 'react';
import { type ResolvedTab } from '../TabBar/hooks/useResolvedTabs';
import PageItem from './PageItem';
import { useStyles } from './styles';
import { type ResolvedPageData } from './types';
interface SectionProps {
isPinned: boolean;
items: ResolvedPageData[];
items: ResolvedTab[];
onClose: () => void;
title: string;
}
@@ -22,7 +22,7 @@ const Section = memo<SectionProps>(({ title, items, isPinned, onClose }) => {
<>
<div className={styles.title}>{title}</div>
{items.map((item) => (
<PageItem isPinned={isPinned} item={item} key={item.reference.id} onClose={onClose} />
<PageItem isPinned={isPinned} item={item} key={item.tab.id} onClose={onClose} />
))}
</>
);
@@ -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<string, { items?: ChatTopic[] }>,
topicId: string,
): ChatTopic | undefined => {
for (const data of Object.values(topicDataMap)) {
const topic = data.items?.find((t) => t.id === topicId);
if (topic) return topic;
}
return undefined;
};
/**
* Hook to create plugin context with access to store data
*/
export const usePluginContext = (): PluginContext => {
const { t } = useTranslation('electron');
const agentMap = useAgentStore((s) => s.agentMap);
const topicDataMap = useChatStore((s) => s.topicDataMap);
const sessionGroups = useSessionStore((s) => s.sessionGroups);
const documents = usePageStore((s) => s.documents);
return useMemo<PluginContext>(
() => ({
getAgentMeta: (agentId: string) => {
const state = useAgentStore.getState();
return agentSelectors.getAgentMetaById(agentId)(state);
},
getDocument: (documentId: string) => {
const state = usePageStore.getState();
return listSelectors.getDocumentById(documentId)(state);
},
getSessionGroup: (groupId: string) => {
const state = useSessionStore.getState();
return sessionGroupSelectors.getGroupById(groupId)(state);
},
getTopic: (topicId: string) => {
// Search across ALL entries in topicDataMap, not just current session
// This ensures we can find topics even after navigating away from the agent page
const state = useChatStore.getState();
return findTopicAcrossAllSessions(state.topicDataMap, topicId);
},
t: (key: string, options?: Record<string, unknown>) =>
t(key as any, options as any) as string,
}),
[agentMap, topicDataMap, sessionGroups, documents, t],
);
};
@@ -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, unknown>) => 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 };
};
@@ -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',
};
@@ -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',
};
@@ -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',
};
@@ -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<string, string> = {
agent: 'navigation.discoverAssistants',
mcp: 'navigation.discoverMcp',
model: 'navigation.discoverModels',
provider: 'navigation.discoverProviders',
};
export const communityPlugin: RecentlyViewedPlugin<'community'> = {
checkExists(_reference: PageReference<'community'>, _ctx: PluginContext): boolean {
return true; // Static page always exists
},
generateId(reference: PageReference<'community'>): string {
const { section } = reference.params;
return section ? `community:${section}` : 'community';
},
generateUrl(reference: PageReference<'community'>): string {
const { section } = reference.params;
return section ? `/community/${section}` : '/community';
},
getDefaultIcon() {
return communityIcon;
},
matchUrl(pathname: string, _searchParams: URLSearchParams): boolean {
return COMMUNITY_PATH_REGEX.test(pathname);
},
parseUrl(pathname: string, _searchParams: URLSearchParams): PageReference<'community'> | null {
const match = pathname.match(COMMUNITY_PATH_REGEX);
if (!match) return null;
const section = match[2]; // Optional section like 'agent', 'model', etc.
const params: CommunityParams = section ? { section } : {};
const id = this.generateId({ params } as PageReference<'community'>);
return createPageReference('community', params, id);
},
priority: 5,
resolve(reference: PageReference<'community'>, ctx: PluginContext): ResolvedPageData {
const { section } = reference.params;
const titleKey = section
? sectionTitleKeys[section] || 'navigation.discover'
: 'navigation.discover';
return {
exists: true,
icon: this.getDefaultIcon!(),
reference,
title: ctx.t(titleKey as any, { ns: 'electron' }) as string,
url: this.generateUrl(reference),
};
},
type: 'community',
};
@@ -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',
};
@@ -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',
};
@@ -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',
};
@@ -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',
};
@@ -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]);
};
@@ -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<string, string> = {
contexts: 'navigation.memoryContexts',
experiences: 'navigation.memoryExperiences',
identities: 'navigation.memoryIdentities',
preferences: 'navigation.memoryPreferences',
};
export const memoryPlugin: RecentlyViewedPlugin<'memory'> = {
checkExists(_reference: PageReference<'memory'>, _ctx: PluginContext): boolean {
return true; // Static page always exists
},
generateId(reference: PageReference<'memory'>): string {
const { section } = reference.params;
return section ? `memory:${section}` : 'memory';
},
generateUrl(reference: PageReference<'memory'>): string {
const { section } = reference.params;
return section ? `/memory/${section}` : '/memory';
},
getDefaultIcon() {
return memoryIcon;
},
matchUrl(pathname: string, _searchParams: URLSearchParams): boolean {
return MEMORY_PATH_REGEX.test(pathname);
},
parseUrl(pathname: string, _searchParams: URLSearchParams): PageReference<'memory'> | null {
const match = pathname.match(MEMORY_PATH_REGEX);
if (!match) return null;
const section = match[2];
const params: MemoryParams = section ? { section } : {};
const id = this.generateId({ params } as PageReference<'memory'>);
return createPageReference('memory', params, id);
},
priority: 5,
resolve(reference: PageReference<'memory'>, ctx: PluginContext): ResolvedPageData {
const { section } = reference.params;
const titleKey = section
? sectionTitleKeys[section] || 'navigation.memory'
: 'navigation.memory';
return {
exists: true,
icon: this.getDefaultIcon!(),
reference,
title: ctx.t(titleKey as any, { ns: 'electron' }) as string,
url: this.generateUrl(reference),
};
},
type: 'memory',
};
@@ -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<NewTabActionResult | null> => {
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<NewTabActionResult | null> => {
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<NewTabActionResult | null> => {
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 };
},
};
};
@@ -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',
};
@@ -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<PageType, BaseRecentlyViewedPlugin> = new Map();
private sortedPlugins: BaseRecentlyViewedPlugin[] = [];
/**
* Register multiple plugins at once
*/
register<T extends PageType>(plugins: [RecentlyViewedPlugin<T>]): void;
register<T extends PageType, T2 extends PageType>(
plugins: [RecentlyViewedPlugin<T>, RecentlyViewedPlugin<T2>],
): void;
register<T extends PageType, T2 extends PageType, T3 extends PageType>(
plugins: [RecentlyViewedPlugin<T>, RecentlyViewedPlugin<T2>, RecentlyViewedPlugin<T3>],
): void;
register<T extends PageType, T2 extends PageType, T3 extends PageType, T4 extends PageType>(
plugins: [
RecentlyViewedPlugin<T>,
RecentlyViewedPlugin<T2>,
RecentlyViewedPlugin<T3>,
RecentlyViewedPlugin<T4>,
],
): void;
register<
T extends PageType,
T2 extends PageType,
T3 extends PageType,
T4 extends PageType,
T5 extends PageType,
>(
plugins: [
RecentlyViewedPlugin<T>,
RecentlyViewedPlugin<T2>,
RecentlyViewedPlugin<T3>,
RecentlyViewedPlugin<T4>,
RecentlyViewedPlugin<T5>,
],
): void;
register<
T extends PageType,
T2 extends PageType,
T3 extends PageType,
T4 extends PageType,
T5 extends PageType,
T6 extends PageType,
>(
plugins: [
RecentlyViewedPlugin<T>,
RecentlyViewedPlugin<T2>,
RecentlyViewedPlugin<T3>,
RecentlyViewedPlugin<T4>,
RecentlyViewedPlugin<T5>,
RecentlyViewedPlugin<T6>,
],
): void;
register<
T extends PageType,
T2 extends PageType,
T3 extends PageType,
T4 extends PageType,
T5 extends PageType,
T6 extends PageType,
T7 extends PageType,
>(
plugins: [
RecentlyViewedPlugin<T>,
RecentlyViewedPlugin<T2>,
RecentlyViewedPlugin<T3>,
RecentlyViewedPlugin<T4>,
RecentlyViewedPlugin<T5>,
RecentlyViewedPlugin<T6>,
RecentlyViewedPlugin<T7>,
],
): void;
register<
T extends PageType,
T2 extends PageType,
T3 extends PageType,
T4 extends PageType,
T5 extends PageType,
T6 extends PageType,
T7 extends PageType,
T8 extends PageType,
>(
plugins: [
RecentlyViewedPlugin<T>,
RecentlyViewedPlugin<T2>,
RecentlyViewedPlugin<T3>,
RecentlyViewedPlugin<T4>,
RecentlyViewedPlugin<T5>,
RecentlyViewedPlugin<T6>,
RecentlyViewedPlugin<T7>,
RecentlyViewedPlugin<T8>,
],
): void;
register<
T extends PageType,
T2 extends PageType,
T3 extends PageType,
T4 extends PageType,
T5 extends PageType,
T6 extends PageType,
T7 extends PageType,
T8 extends PageType,
T9 extends PageType,
>(
plugins: [
RecentlyViewedPlugin<T>,
RecentlyViewedPlugin<T2>,
RecentlyViewedPlugin<T3>,
RecentlyViewedPlugin<T4>,
RecentlyViewedPlugin<T5>,
RecentlyViewedPlugin<T6>,
RecentlyViewedPlugin<T7>,
RecentlyViewedPlugin<T8>,
RecentlyViewedPlugin<T9>,
],
): void;
register<
T extends PageType,
T2 extends PageType,
T3 extends PageType,
T4 extends PageType,
T5 extends PageType,
T6 extends PageType,
T7 extends PageType,
T8 extends PageType,
T9 extends PageType,
T10 extends PageType,
>(
plugins: [
RecentlyViewedPlugin<T>,
RecentlyViewedPlugin<T2>,
RecentlyViewedPlugin<T3>,
RecentlyViewedPlugin<T4>,
RecentlyViewedPlugin<T5>,
RecentlyViewedPlugin<T6>,
RecentlyViewedPlugin<T7>,
RecentlyViewedPlugin<T8>,
RecentlyViewedPlugin<T9>,
RecentlyViewedPlugin<T10>,
],
): void;
register<
T extends PageType,
T2 extends PageType,
T3 extends PageType,
T4 extends PageType,
T5 extends PageType,
T6 extends PageType,
T7 extends PageType,
T8 extends PageType,
T9 extends PageType,
T10 extends PageType,
T11 extends PageType,
>(
plugins: [
RecentlyViewedPlugin<T>,
RecentlyViewedPlugin<T2>,
RecentlyViewedPlugin<T3>,
RecentlyViewedPlugin<T4>,
RecentlyViewedPlugin<T5>,
RecentlyViewedPlugin<T6>,
RecentlyViewedPlugin<T7>,
RecentlyViewedPlugin<T8>,
RecentlyViewedPlugin<T9>,
RecentlyViewedPlugin<T10>,
RecentlyViewedPlugin<T11>,
],
): void;
register(plugins: BaseRecentlyViewedPlugin[]): void {
for (const plugin of plugins) {
this.plugins.set(plugin.type, plugin);
}
this.updateSortedPlugins();
}
/**
* Parse URL into a page reference using registered plugins
* Returns null if no plugin matches
*/
parseUrl(pathname: string, search: string): PageReference | null {
const searchParams = new URLSearchParams(search);
for (const plugin of this.sortedPlugins) {
if (plugin.matchUrl(pathname, searchParams)) {
const reference = plugin.parseUrl(pathname, searchParams);
if (reference) {
return reference;
}
}
}
return null;
}
/**
* Resolve a page reference into display data
*/
resolve(reference: PageReference, ctx: PluginContext): ResolvedPageData | null {
const plugin = this.plugins.get(reference.type);
if (!plugin) return null;
return plugin.resolve(reference, ctx);
}
/**
* 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();
@@ -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<string, string> = {
library: 'navigation.knowledgeBase',
};
export const resourcePlugin: RecentlyViewedPlugin<'resource'> = {
checkExists(_reference: PageReference<'resource'>, _ctx: PluginContext): boolean {
return true; // Static page always exists
},
generateId(reference: PageReference<'resource'>): string {
const { section } = reference.params;
return section ? `resource:${section}` : 'resource';
},
generateUrl(reference: PageReference<'resource'>): string {
const { section } = reference.params;
return section ? `/resource/${section}` : '/resource';
},
getDefaultIcon() {
return resourceIcon;
},
matchUrl(pathname: string, _searchParams: URLSearchParams): boolean {
return RESOURCE_PATH_REGEX.test(pathname);
},
parseUrl(pathname: string, _searchParams: URLSearchParams): PageReference<'resource'> | null {
const match = pathname.match(RESOURCE_PATH_REGEX);
if (!match) return null;
const section = match[2];
const params: ResourceParams = section ? { section } : {};
const id = this.generateId({ params } as PageReference<'resource'>);
return createPageReference('resource', params, id);
},
priority: 5,
resolve(reference: PageReference<'resource'>, ctx: PluginContext): ResolvedPageData {
const { section } = reference.params;
const titleKey = section
? sectionTitleKeys[section] || 'navigation.resources'
: 'navigation.resources';
return {
exists: true,
icon: this.getDefaultIcon!(),
reference,
title: ctx.t(titleKey as any, { ns: 'electron' }) as string,
url: this.generateUrl(reference),
};
},
type: 'resource',
};
@@ -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',
};
@@ -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<NewTabActionResult | null>;
}
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, unknown>) => string;
}
// ======== Plugin Interface ======== //
/**
* Base plugin interface (non-generic for registry use)
*/
export interface BaseRecentlyViewedPlugin {
/**
* Check if the underlying data exists
*/
checkExists: (reference: PageReference, ctx: PluginContext) => boolean;
/**
* 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<T extends PageType = PageType> {
/**
* Check if the underlying data exists
* Used to filter out stale entries
*/
checkExists: (reference: PageReference<T>, 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<T>, ctx: PluginContext) => NewTabAction | null;
/**
* Generate unique ID from reference params
* e.g., "agent:abc123" or "agent-topic:abc123:topic456"
*/
generateId: (reference: PageReference<T>) => string;
/**
* Generate navigation URL from reference
*/
generateUrl: (reference: PageReference<T>) => string;
/**
* Get default icon for this page type
*/
getDefaultIcon?: () => LucideIcon;
/**
* Check if URL matches this plugin
*/
matchUrl: (pathname: string, searchParams: URLSearchParams) => boolean;
/**
* Called when a tab with this reference type is activated.
* Use to perform store-level state transitions (e.g. switchTopic).
*/
onActivate?: (reference: PageReference<T>) => void;
/**
* Parse URL into a page reference
* Returns null if URL doesn't match
*/
parseUrl: (pathname: string, searchParams: URLSearchParams) => PageReference<T> | null;
/**
* Priority for URL matching (higher = checked first)
* Used when multiple plugins could match the same URL
*/
readonly priority?: number;
/**
* Resolve reference into display data
*/
resolve: (reference: PageReference<T>, ctx: PluginContext) => ResolvedPageData;
/**
* Page type this plugin handles
*/
readonly type: T;
}
// ======== Helper Types ======== //
/**
* Helper to create typed page reference
*/
export function createPageReference<T extends PageType>(
type: T,
params: PageParamsMap[T],
id: string,
): PageReference<T> {
return {
id,
lastVisited: Date.now(),
params,
type,
};
}
@@ -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;
@@ -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<T extends PageType = PageType> {
/**
* Cached display data for when store data is unavailable
* This ensures pinned pages can display even after app restart
*/
cached?: CachedPageData;
/**
* Unique identifier combining type and params
* e.g., "agent:abc123" or "agent-topic:abc123:topic456"
*/
id: string;
/**
* Timestamp of last visit
*/
lastVisited: number;
/**
* Type-specific parameters
*/
params: PageParamsMap[T];
/**
* Page type
*/
type: T;
/**
* Visit count for sorting/analytics
*/
visitCount?: number;
}
// ======== Resolved Page Data (Display) ======== //
/**
* Resolved page data ready for rendering
* Contains all display information generated by plugins
*/
export interface ResolvedPageData {
/**
* Avatar URL for agent/group pages
*/
avatar?: string;
/**
* Background color for avatar
*/
backgroundColor?: string;
/**
* Whether the underlying data exists
* Pages with exists=false should be filtered out
*/
exists: boolean;
/**
* Icon to display
*/
icon?: LucideIcon;
/**
* Original reference for navigation and pin/unpin
*/
reference: PageReference;
/**
* Display title
*/
title: string;
/**
* Generated URL for navigation
*/
url: string;
}
@@ -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<TabItemProps>(
}) => {
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<TabItemProps>(
gap={6}
onClick={handleClick}
>
{item.avatar ? (
{meta.avatar ? (
<span className={styles.avatarWrapper}>
<Avatar
emojiScaleWithBackground
avatar={item.avatar}
background={item.backgroundColor}
avatar={meta.avatar}
background={meta.backgroundColor}
shape="square"
size={16}
/>
@@ -117,9 +118,9 @@ const TabItem = memo<TabItemProps>(
{showUnreadDot && <span aria-label={t('tab.unread')} className={styles.unreadDot} />}
</span>
) : (
item.icon && (
meta.icon && (
<span className={styles.avatarWrapper}>
<Icon className={styles.tabIcon} icon={item.icon} size="small" />
<Icon className={styles.tabIcon} icon={meta.icon} size="small" />
{isRunning && <span aria-label={t('tab.running')} className={styles.runningDot} />}
{showUnreadDot && (
<span aria-label={t('tab.unread')} className={styles.unreadDot} />
@@ -127,7 +128,7 @@ const TabItem = memo<TabItemProps>(
</span>
)
)}
<span className={styles.tabTitle}>{item.title}</span>
<span className={styles.tabTitle}>{meta.title}</span>
<ActionIcon className={styles.closeIcon} icon={X} size="small" onClick={handleClose} />
</Flexbox>
</ContextMenuTrigger>
@@ -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, unknown>) => 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 };
};
@@ -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);
});
@@ -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);
});
+23 -38
View File
@@ -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<HTMLDivElement>(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) => (
<TabItem
index={index}
isActive={tab.reference.id === activeTabId}
isActive={tab.tab.id === activeTabId}
item={tab}
key={tab.reference.id}
key={tab.tab.id}
totalCount={tabs.length}
onActivate={handleActivate}
onClose={handleClose}
@@ -0,0 +1,87 @@
import { 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 {
FALLBACK_ICON,
guardedMergeCache,
matchRouteMeta,
pickMeaningful,
} from './resolveRouteMeta';
const agentMeta: RouteMeta = { icon: MessageSquare, titleKey: 'navigation.chat' };
const fixtureRoutes: RouteObject[] = [
{
children: [{ handle: { meta: agentMeta }, path: 'agent/:aid' }, { path: 'group/:gid' }],
path: '/',
},
];
describe('matchRouteMeta', () => {
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();
});
});
@@ -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<string, string | undefined>;
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;
@@ -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');
});
});
@@ -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');
});
});
});
+121 -19
View File
@@ -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<string, string | undefined>;
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<string>();
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 {
@@ -0,0 +1,9 @@
import { type DynamicRouteMeta } from '@/spa/router/routeMeta';
export interface TabItem {
cached?: DynamicRouteMeta;
id: string;
lastVisited: number;
url: string;
visitCount?: number;
}
@@ -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();
});
});
@@ -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 };
};
+15 -26
View File
@@ -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<PageEditorCanvasProps>(({ header, fullWidthHeader
if (fullWidthHeader) {
return (
<>
<PageTitle />
<Flexbox
height={'100%'}
style={{ backgroundColor: cssVar.colorBgContainer }}
width={'100%'}
>
{headerSlot}
<Flexbox horizontal flex={1} style={{ minHeight: 0 }} width={'100%'}>
{editorPane}
<RightPanel />
</Flexbox>
<Flexbox height={'100%'} style={{ backgroundColor: cssVar.colorBgContainer }} width={'100%'}>
{headerSlot}
<Flexbox horizontal flex={1} style={{ minHeight: 0 }} width={'100%'}>
{editorPane}
<RightPanel />
</Flexbox>
</>
</Flexbox>
);
}
return (
<>
<PageTitle />
<Flexbox
horizontal
height={'100%'}
style={{ backgroundColor: cssVar.colorBgContainer }}
width={'100%'}
>
{editorPane}
<RightPanel />
</Flexbox>
</>
<Flexbox
horizontal
height={'100%'}
style={{ backgroundColor: cssVar.colorBgContainer }}
width={'100%'}
>
{editorPane}
<RightPanel />
</Flexbox>
);
});
@@ -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 && <PageTitle title={pageTitle} />;
});
export default Title;
@@ -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<DocumentItemProps>(({ 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
@@ -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 },
-13
View File
@@ -1,13 +0,0 @@
'use client';
import { memo } from 'react';
import PageTitle from '@/components/PageTitle';
const Title = memo(() => {
return <PageTitle title="Pages" />;
});
Title.displayName = 'PageTitle';
export default Title;
-1
View File
@@ -1,2 +1 @@
export { default as PageLayout } from './PageLayout';
export { default as PageTitle } from './PageTitle';
+65
View File
@@ -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,
};
},
});
-3
View File
@@ -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
@@ -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<string, string | undefined>;
useDynamicMeta?: RouteMeta['useDynamicMeta'];
}
const Runner = memo<DynamicMetaRunnerProps>(({ 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<DynamicMetaRunnerProps>((props) => (
<SafeBoundary
onError={(error) => {
log('useDynamicMeta threw, falling back to static meta: %O', error);
props.onResolve({});
}}
>
<Runner {...props} />
</SafeBoundary>
));
DynamicMetaRunner.displayName = 'DynamicMetaRunnerBoundary';
export default DynamicMetaRunner;
@@ -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<string, string | undefined>;
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<typeof ReactModule>('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(<RouteMetaBridge />);
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);
});
});
});
@@ -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<string, string | undefined>;
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<DynamicRouteMetaState>({ 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 (
<DynamicMetaRunner
key={matched.routeId}
params={matched.params}
useDynamicMeta={matched.meta.useDynamicMeta}
onResolve={handleResolve}
/>
);
});
RouteMetaBridge.displayName = 'RouteMetaBridge';
export default RouteMetaBridge;
+1
View File
@@ -0,0 +1 @@
export { default as RouteMetaBridge } from './RouteMetaBridge';
+39
View File
@@ -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,
};
},
});
+1
View File
@@ -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',
+2
View File
@@ -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 (
<HotkeysProvider initiallyActiveScopes={[HotkeyScopeEnum.Global]}>
<RouteMetaBridge />
<Suspense fallback={null}>
{isDesktop && <DesktopAutoOidcOnFirstOpen />}
{isDesktop && <DesktopNavigationBridge />}
@@ -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 }) => (
<div data-active={String(active)} data-testid="nav-item">
@@ -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<TopicItemProps>(({ 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({
@@ -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);
},
},
{
@@ -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 <PageTitle title={[topicTitle, agentTitle].filter(Boolean).join(' · ')} />;
});
export default Title;
@@ -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,
};
},
});
@@ -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),
};
},
});
@@ -66,10 +66,6 @@ vi.mock('./features/Conversation/WorkingSidebar', () => ({
default: () => <div data-testid="working-sidebar" />,
}));
vi.mock('./features/PageTitle', () => ({
default: () => <div data-testid="page-title" />,
}));
vi.mock('./features/Portal', () => ({
default: () => <div data-testid="portal" />,
}));
+1 -6
View File
@@ -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 ? (
<>
<PageTitle />
<TopicInPopupGuard popup={popup} />
</>
<TopicInPopupGuard popup={popup} />
) : (
<>
<PageTitle />
<Flexbox
horizontal
height={'100%'}
-2
View File
@@ -5,14 +5,12 @@ import { memo } from 'react';
import Conversation from './features/Conversation';
import ChatHydration from './features/Conversation/ChatHydration';
import PageTitle from './features/PageTitle';
import TelemetryNotification from './features/TelemetryNotification';
const ChatPage = memo(() => {
return (
<>
<ChatHydration />
<PageTitle />
<Flexbox
height={'100%'}
style={{ minHeight: 0, overflow: 'hidden', position: 'relative' }}
@@ -8,7 +8,6 @@ import { useTranslation } from 'react-i18next';
import DotsLoading from '@/components/DotsLoading';
import { isDesktop } from '@/const/version';
import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins';
import NavItem from '@/features/NavPanel/components/NavItem';
import { useFocusTopicPopup } from '@/features/TopicPopupGuard/useTopicPopupsRegistry';
import { useAgentGroupStore } from '@/store/agentGroup';
@@ -137,12 +136,9 @@ const TopicItem = memo<TopicItemProps>(({ 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({
@@ -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);
},
},
{
@@ -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 <PageTitle title={[topicTitle, agentTitle].filter(Boolean).join(' · ')} />;
});
export default Title;
@@ -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,
};
},
});
@@ -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 (
<>
<ChatHydration />
<PageTitle />
<TopicInPopupGuard popup={popup} />
</>
);
@@ -33,7 +31,6 @@ const ChatPage = memo(() => {
return (
<>
<PageTitle />
<Flexbox
horizontal
height={'100%'}
-2
View File
@@ -4,14 +4,12 @@ import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import Conversation from './features/Conversation';
import PageTitle from './features/PageTitle';
import Portal from './features/Portal';
import TelemetryNotification from './features/TelemetryNotification';
const ChatPage = memo(() => {
return (
<>
<PageTitle />
<Flexbox
horizontal
height={'100%'}
-6
View File
@@ -1,21 +1,15 @@
import { Flexbox } from '@lobehub/ui';
import { type FC } from 'react';
import { useLocation } from 'react-router-dom';
import HomePageTracker from '@/components/Analytics/HomePageTracker';
import PageTitle from '@/components/PageTitle';
import NavHeader from '@/features/NavHeader';
import WideScreenContainer from '@/features/WideScreenContainer';
import HomeContent from './features';
const Home: FC = () => {
const { pathname } = useLocation();
const isHomeRoute = pathname === '/';
return (
<>
{isHomeRoute && <PageTitle title="" />}
<HomePageTracker />
<NavHeader />
<Flexbox
+3 -12
View File
@@ -7,14 +7,9 @@ import { createStoreUpdater } from 'zustand-utils';
import Loading from '@/components/Loading/BrandTextLoading';
import PageExplorer from '@/features/PageExplorer';
import { PageTitle } from '@/features/Pages';
import { usePageStore } from '@/store/page';
import { getIdFromIdentifier } from '@/utils/identifier';
/**
* Pages route - dedicated page for managing documents/pages
* This is extracted from the /resource route to have its own dedicated space
*/
const PagesPage = memo(() => {
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 (
<>
<PageTitle />
<Suspense fallback={<Loading debugId="PagesPage" />}>
<PageExplorer pageId={pageId} />
</Suspense>
</>
<Suspense fallback={<Loading debugId="PagesPage" />}>
<PageExplorer pageId={pageId} />
</Suspense>
);
});
+3 -11
View File
@@ -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 (
<>
<PageTitle />
<Suspense fallback={<Loading debugId="PagesPage" />}>
<PageExplorerPlaceholder />
</Suspense>
</>
<Suspense fallback={<Loading debugId="PagesPage" />}>
<PageExplorerPlaceholder />
</Suspense>
);
});
@@ -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 };
},
});
+2
View File
@@ -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 (
<>
<RouteMetaBridge />
<Suspense fallback={null}>{showCloudPromotion && <CloudBanner mobile />}</Suspense>
<MarketAuthProvider isDesktop={false}>
<Suspense fallback={<Loading debugId="MobileMainLayout > Outlet" />}>
-2
View File
@@ -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 (
<>
<ChatHydration />
<PageTitle />
<ConversationArea />
<Topic />
<PortalPanel mobile />
+1 -6
View File
@@ -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 (
<MobileContentLayout header={<MobileHeader />}>
<PageTitle title={t('header.sessionWithName', { name: title })} />
<Tabs
compact
activeKey={tab}
+3 -7
View File
@@ -6,7 +6,6 @@ import { memo, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import useSWR from 'swr';
import PageTitle from '@/components/PageTitle';
import { lambdaClient } from '@/libs/trpc/client';
import { useAgentStore } from '@/store/agent';
import { useAgentGroupStore } from '@/store/agentGroup';
@@ -82,12 +81,9 @@ const Title = memo(() => {
return (
data?.title && (
<>
<PageTitle title={data.title} />
<Text ellipsis strong align={'center'} fontSize={16} style={{ textAlign: 'center' }}>
{data.title}
</Text>
</>
<Text ellipsis strong align={'center'} fontSize={16} style={{ textAlign: 'center' }}>
{data.title}
</Text>
)
);
});
@@ -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<PropsWithChildren>(({ children }) => {
return (
<Flexbox className={styles.outerContainer} height={'100%'} padding={8} width={'100%'}>
<RouteMetaBridge />
<Flexbox
className={cx(isDarkMode ? styles.innerContainerDark : styles.innerContainerLight)}
height={'100%'}
@@ -1,11 +1,22 @@
'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 DesktopOnboarding from '@/routes/(desktop)/desktop-onboarding';
// Layouts — sync import (Electron local, no network overhead)
import DesktopMainLayout from '@/routes/(main)/_layout';
@@ -21,6 +32,8 @@ import DesktopAgentChatLayout from '@/routes/(main)/agent/(chat)/_layout';
import AgentTopicNotebookRedirectPage from '@/routes/(main)/agent/[topicId]/page';
import AgentTopicNotebookDocPage from '@/routes/(main)/agent/[topicId]/page/[docId]';
import AgentChannelPage from '@/routes/(main)/agent/channel';
import { agentRouteMeta } from '@/routes/(main)/agent/features/routeMeta';
import { agentTopicPageRouteMeta } from '@/routes/(main)/agent/features/topicPageRouteMeta';
import AgentPageRedirectPage from '@/routes/(main)/agent/page';
import AgentProfilePage from '@/routes/(main)/agent/profile';
import AgentTaskDetailRoute from '@/routes/(main)/agent/task/[taskId]';
@@ -57,6 +70,7 @@ import EvalRunDetailPage from '@/routes/(main)/eval/bench/[benchmarkId]/runs/[ru
import EvalCaseDetailPage from '@/routes/(main)/eval/bench/[benchmarkId]/runs/[runId]/cases/[caseId]';
import GroupPage from '@/routes/(main)/group';
import DesktopGroupLayout from '@/routes/(main)/group/_layout';
import { groupRouteMeta } from '@/routes/(main)/group/features/routeMeta';
import GroupProfilePage from '@/routes/(main)/group/profile';
import DesktopMemoryLayout from '@/routes/(main)/memory/_layout';
import MemoryHomePage from '@/routes/(main)/memory/(home)';
@@ -76,11 +90,13 @@ import ResourceLibraryLayout from '@/routes/(main)/resource/library/_layout';
import ResourceLibrarySlugPage from '@/routes/(main)/resource/library/[slug]';
import SettingsTabPage from '@/routes/(main)/settings';
import SettingsLayout from '@/routes/(main)/settings/_layout';
import { settingsRouteMeta } from '@/routes/(main)/settings/features/routeMeta';
import { ProviderDetailPage, ProviderLayout } from '@/routes/(main)/settings/provider';
import TaskDetailRoute from '@/routes/(main)/task/[taskId]';
import AllTasksPage from '@/routes/(main)/tasks';
import ShareTopicPage from '@/routes/share/t/[id]';
import ShareTopicLayout from '@/routes/share/t/[id]/_layout';
import { routeMeta } from '@/spa/router/routeMeta';
import { ErrorBoundary, redirectElement } from '@/utils/router';
// Desktop router configuration — all sync imports for Electron local build
@@ -100,22 +116,26 @@ export const desktopRoutes: RouteObject[] = [
children: [
{
element: <AgentPage />,
handle: { meta: agentRouteMeta },
index: true,
},
{
children: [
{
element: <AgentPage />,
handle: { meta: agentRouteMeta },
index: true,
},
{
children: [
{
element: <AgentTopicNotebookRedirectPage />,
handle: { meta: agentTopicPageRouteMeta },
index: true,
},
{
element: <AgentTopicNotebookDocPage />,
handle: { meta: agentTopicPageRouteMeta },
path: ':docId',
},
],
@@ -141,6 +161,7 @@ export const desktopRoutes: RouteObject[] = [
},
{
element: <AgentTaskDetailRoute />,
handle: { meta: taskRouteMeta },
path: 'task/:taskId',
},
],
@@ -163,6 +184,7 @@ export const desktopRoutes: RouteObject[] = [
children: [
{
element: <GroupPage />,
handle: { meta: groupRouteMeta },
index: true,
},
{
@@ -188,6 +210,12 @@ export const desktopRoutes: RouteObject[] = [
children: [
{
element: <CommunityListAgentPage />,
handle: {
meta: routeMeta({
icon: ShapesIcon,
titleKey: 'navigation.discoverAssistants',
}),
},
index: true,
},
],
@@ -198,6 +226,9 @@ export const desktopRoutes: RouteObject[] = [
children: [
{
element: <CommunityListModelPage />,
handle: {
meta: routeMeta({ icon: ShapesIcon, titleKey: 'navigation.discoverModels' }),
},
index: true,
},
],
@@ -206,12 +237,18 @@ export const desktopRoutes: RouteObject[] = [
},
{
element: <CommunityListProviderPage />,
handle: {
meta: routeMeta({ icon: ShapesIcon, titleKey: 'navigation.discoverProviders' }),
},
path: 'provider',
},
{
children: [
{
element: <CommunityListSkillPage />,
handle: {
meta: routeMeta({ icon: ShapesIcon, titleKey: 'navigation.discover' }),
},
index: true,
},
],
@@ -222,6 +259,9 @@ export const desktopRoutes: RouteObject[] = [
children: [
{
element: <CommunityListMcpPage />,
handle: {
meta: routeMeta({ icon: ShapesIcon, titleKey: 'navigation.discoverMcp' }),
},
index: true,
},
],
@@ -230,6 +270,9 @@ export const desktopRoutes: RouteObject[] = [
},
{
element: <CommunityListHomePage />,
handle: {
meta: routeMeta({ icon: ShapesIcon, titleKey: 'navigation.discover' }),
},
index: true,
},
],
@@ -283,6 +326,9 @@ export const desktopRoutes: RouteObject[] = [
children: [
{
element: <ResourceHomePage />,
handle: {
meta: routeMeta({ icon: LibraryBigIcon, titleKey: 'navigation.resources' }),
},
index: true,
},
],
@@ -293,10 +339,16 @@ export const desktopRoutes: RouteObject[] = [
children: [
{
element: <ResourceLibraryPage />,
handle: {
meta: routeMeta({ icon: LibraryBigIcon, titleKey: 'navigation.knowledgeBase' }),
},
index: true,
},
{
element: <ResourceLibrarySlugPage />,
handle: {
meta: routeMeta({ icon: LibraryBigIcon, titleKey: 'navigation.knowledgeBase' }),
},
path: ':slug',
},
],
@@ -325,21 +377,29 @@ export const desktopRoutes: RouteObject[] = [
},
{
element: <ProviderDetailPage />,
handle: {
meta: routeMeta({ icon: Settings, titleKey: 'navigation.provider' }),
},
path: ':providerId',
},
],
element: <ProviderLayout />,
handle: {
meta: routeMeta({ icon: Settings, titleKey: 'navigation.provider' }),
},
path: 'provider',
},
// Other settings tabs
{
element: <SettingsTabPage />,
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: <SettingsTabPage />,
handle: { meta: settingsRouteMeta },
path: ':tab/:sub',
},
],
@@ -353,26 +413,44 @@ export const desktopRoutes: RouteObject[] = [
children: [
{
element: <MemoryHomePage />,
handle: {
meta: routeMeta({ icon: BrainCircuit, titleKey: 'navigation.memory' }),
},
index: true,
},
{
element: <MemoryIdentitiesPage />,
handle: {
meta: routeMeta({ icon: BrainCircuit, titleKey: 'navigation.memoryIdentities' }),
},
path: 'identities',
},
{
element: <MemoryContextsPage />,
handle: {
meta: routeMeta({ icon: BrainCircuit, titleKey: 'navigation.memoryContexts' }),
},
path: 'contexts',
},
{
element: <MemoryPreferencesPage />,
handle: {
meta: routeMeta({ icon: BrainCircuit, titleKey: 'navigation.memoryPreferences' }),
},
path: 'preferences',
},
{
element: <MemoryExperiencesPage />,
handle: {
meta: routeMeta({ icon: BrainCircuit, titleKey: 'navigation.memoryExperiences' }),
},
path: 'experiences',
},
{
element: <MemoryActivitiesPage />,
handle: {
meta: routeMeta({ icon: BrainCircuit, titleKey: 'navigation.memory' }),
},
path: 'activities',
},
],
@@ -399,6 +477,9 @@ export const desktopRoutes: RouteObject[] = [
children: [
{
element: <ImagePage />,
handle: {
meta: routeMeta({ icon: Image, titleKey: 'navigation.image' }),
},
index: true,
},
],
@@ -463,6 +544,7 @@ export const desktopRoutes: RouteObject[] = [
children: [
{
element: <AllTasksPage />,
handle: { meta: tasksRouteMeta },
index: true,
},
],
@@ -473,6 +555,7 @@ export const desktopRoutes: RouteObject[] = [
children: [
{
element: <TaskDetailRoute />,
handle: { meta: taskRouteMeta },
path: ':taskId',
},
],
@@ -488,10 +571,14 @@ export const desktopRoutes: RouteObject[] = [
children: [
{
element: <PageIndexPage />,
handle: {
meta: routeMeta({ icon: FilePenIcon, titleKey: 'navigation.pages' }),
},
index: true,
},
{
element: <PageDetailPage />,
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
+90
View File
@@ -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
@@ -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();
+10 -1
View File
@@ -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',
},
],
+44
View File
@@ -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<NewTabActionResult | null>;
}
export interface RouteMeta extends StaticRouteMeta {
createNewTab?: (params: Record<string, string | undefined>) => NewTabAction | null;
useDynamicMeta?: (params: Record<string, string | undefined>) => 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;
};
@@ -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',
});
});
});
});
@@ -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 => {
+24 -45
View File
@@ -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');
+30 -29
View File
@@ -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();