mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
♻️ 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:
@@ -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",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"navigation.recentView": "最近访问",
|
||||
"navigation.resources": "资源",
|
||||
"navigation.settings": "设置",
|
||||
"navigation.task": "任务",
|
||||
"navigation.tasks": "任务",
|
||||
"navigation.unpin": "取消固定",
|
||||
"notification.finishChatGeneration": "AI 消息已生成完毕",
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,2 +1 @@
|
||||
export { default as PageLayout } from './PageLayout';
|
||||
export { default as PageTitle } from './PageTitle';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as RouteMetaBridge } from './RouteMetaBridge';
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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" />,
|
||||
}));
|
||||
|
||||
@@ -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%'}
|
||||
|
||||
@@ -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%'}
|
||||
|
||||
@@ -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%'}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
@@ -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" />}>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user