diff --git a/apps/desktop/src/main/core/infrastructure/BackendProxyProtocolManager.ts b/apps/desktop/src/main/core/infrastructure/BackendProxyProtocolManager.ts index 3fbf7c0071..97d9aa434a 100644 --- a/apps/desktop/src/main/core/infrastructure/BackendProxyProtocolManager.ts +++ b/apps/desktop/src/main/core/infrastructure/BackendProxyProtocolManager.ts @@ -37,7 +37,7 @@ export class BackendProxyProtocolManager { * Debounce timer for authorization required notifications. * Prevents multiple rapid 401 responses from triggering duplicate notifications. */ - // eslint-disable-next-line no-undef + private authRequiredDebounceTimer: NodeJS.Timeout | null = null; private static readonly AUTH_REQUIRED_DEBOUNCE_MS = 1000; @@ -119,7 +119,6 @@ export class BackendProxyProtocolManager { } appendVercelCookie(headers); - // eslint-disable-next-line no-undef const requestInit: RequestInit & { duplex?: 'half' } = { headers, method: request.method, @@ -141,13 +140,7 @@ export class BackendProxyProtocolManager { } catch (error) { this.logger.error(`${logPrefix} upstream fetch failed: ${rewrittenUrl}`, error); - return new Response('Upstream fetch failed, target url: ' + rewrittenUrl, { - headers: { - 'Content-Type': 'text/plain; charset=utf-8', - }, - status: 502, - statusText: 'Bad Gateway', - }); + throw error; } const responseHeaders = new Headers(upstreamResponse.headers); @@ -183,7 +176,7 @@ export class BackendProxyProtocolManager { }); } catch (error) { this.logger.error(`${logPrefix} protocol.handle error:`, error); - return null; + throw error; } }); diff --git a/apps/desktop/src/main/core/infrastructure/__tests__/BackendProxyProtocolManager.test.ts b/apps/desktop/src/main/core/infrastructure/__tests__/BackendProxyProtocolManager.test.ts index 36173c00cb..54cb9f7f35 100644 --- a/apps/desktop/src/main/core/infrastructure/__tests__/BackendProxyProtocolManager.test.ts +++ b/apps/desktop/src/main/core/infrastructure/__tests__/BackendProxyProtocolManager.test.ts @@ -185,7 +185,7 @@ describe('BackendProxyProtocolManager', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('should respond with 502 when upstream fetch throws', async () => { + it('should throw when upstream fetch throws', async () => { const manager = new BackendProxyProtocolManager(); const session = { protocol: mockProtocol } as any; @@ -201,13 +201,12 @@ describe('BackendProxyProtocolManager', () => { }); const handler = protocolHandlerRef.current; - const response = await handler({ - headers: new Headers(), - method: 'GET', - url: 'lobe-backend://app/trpc/hello', - } as any); - - expect(response.status).toBe(502); - expect(await response.text()).toContain('Upstream fetch failed'); + await expect( + handler({ + headers: new Headers(), + method: 'GET', + url: 'lobe-backend://app/trpc/hello', + } as any), + ).rejects.toThrow('network down'); }); }); diff --git a/locales/en-US/electron.json b/locales/en-US/electron.json index b5e440f28a..c8970a89cb 100644 --- a/locales/en-US/electron.json +++ b/locales/en-US/electron.json @@ -93,6 +93,10 @@ "sync.mode.useSelfHosted": "Use a self-hosted instance?", "sync.selfHosted.description": "Community version that you can deploy yourself", "sync.selfHosted.title": "Self-Hosted Instance", + "tab.closeCurrentTab": "Close Tab", + "tab.closeLeftTabs": "Close Tabs to the Left", + "tab.closeOtherTabs": "Close Other Tabs", + "tab.closeRightTabs": "Close Tabs to the Right", "updater.checkingUpdate": "Checking for updates", "updater.checkingUpdateDesc": "Retrieving version information...", "updater.downloadNewVersion": "Download new version", diff --git a/locales/en-US/file.json b/locales/en-US/file.json index 4f35051af0..944587d415 100644 --- a/locales/en-US/file.json +++ b/locales/en-US/file.json @@ -100,6 +100,7 @@ "pageEditor.saving": "Saving...", "pageEditor.titlePlaceholder": "Untitled", "pageEditor.wordCount": "{{wordCount}} words", + "pageList.actions.openInNewTab": "Open in New Tab", "pageList.copyContent": "Copy Full Text", "pageList.duplicate": "Duplicate", "pageList.empty": "No pages yet. Click the button above to create your first one.", diff --git a/locales/en-US/topic.json b/locales/en-US/topic.json index e3d688319a..42e625a5b0 100644 --- a/locales/en-US/topic.json +++ b/locales/en-US/topic.json @@ -7,6 +7,7 @@ "actions.duplicate": "Duplicate", "actions.export": "Export Topics", "actions.import": "Import Conversation", + "actions.openInNewTab": "Open in New Tab", "actions.openInNewWindow": "Open in a new window", "actions.removeAll": "Delete All Topics", "actions.removeUnstarred": "Delete Unstarred Topics", diff --git a/locales/zh-CN/electron.json b/locales/zh-CN/electron.json index 0c8250146a..2efb70a387 100644 --- a/locales/zh-CN/electron.json +++ b/locales/zh-CN/electron.json @@ -93,6 +93,10 @@ "sync.mode.useSelfHosted": "使用自托管实例?", "sync.selfHosted.description": "自行部署的社区版本", "sync.selfHosted.title": "自托管实例", + "tab.closeCurrentTab": "关闭标签页", + "tab.closeLeftTabs": "关闭左侧标签页", + "tab.closeOtherTabs": "关闭其他标签页", + "tab.closeRightTabs": "关闭右侧标签页", "updater.checkingUpdate": "检查新版本", "updater.checkingUpdateDesc": "正在获取版本信息…", "updater.downloadNewVersion": "下载新版本", diff --git a/locales/zh-CN/file.json b/locales/zh-CN/file.json index 5b326db7e3..9739e95ee4 100644 --- a/locales/zh-CN/file.json +++ b/locales/zh-CN/file.json @@ -100,6 +100,7 @@ "pageEditor.saving": "正在保存…", "pageEditor.titlePlaceholder": "无标题", "pageEditor.wordCount": "{{wordCount}} 字", + "pageList.actions.openInNewTab": "在新标签页中打开", "pageList.copyContent": "复制全文", "pageList.duplicate": "创建副本", "pageList.empty": "还没有文稿。点击上方按钮创建第一篇", diff --git a/locales/zh-CN/topic.json b/locales/zh-CN/topic.json index c0329ea8f0..2f1a72f2da 100644 --- a/locales/zh-CN/topic.json +++ b/locales/zh-CN/topic.json @@ -7,6 +7,7 @@ "actions.duplicate": "复制", "actions.export": "导出话题", "actions.import": "导入对话", + "actions.openInNewTab": "在新标签页中打开", "actions.openInNewWindow": "打开独立窗口", "actions.removeAll": "删除全部话题", "actions.removeUnstarred": "删除未收藏话题", diff --git a/packages/database/src/models/__tests__/chatGroup.test.ts b/packages/database/src/models/__tests__/chatGroup.test.ts index 62bddffc95..b3228251da 100644 --- a/packages/database/src/models/__tests__/chatGroup.test.ts +++ b/packages/database/src/models/__tests__/chatGroup.test.ts @@ -1,4 +1,5 @@ // @vitest-environment node +import { CHAT_GROUP_SESSION_ID_PREFIX } from '@lobechat/types'; import { eq } from 'drizzle-orm'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -294,7 +295,7 @@ describe('ChatGroupModel', () => { allowDM: true, revealDM: false, }); - expect(result.id.startsWith('cg_')).toBe(true); + expect(result.id.startsWith(CHAT_GROUP_SESSION_ID_PREFIX)).toBe(true); }); it('should create group with custom ID', async () => { diff --git a/packages/types/src/session/agentSession.ts b/packages/types/src/session/agentSession.ts index ebf13c0735..a7cbe1a1c1 100644 --- a/packages/types/src/session/agentSession.ts +++ b/packages/types/src/session/agentSession.ts @@ -2,6 +2,11 @@ import type { AgentItem, LobeAgentConfig } from '../agent'; import type { NewChatGroupAgent } from '../agentGroup'; import type { MetaData } from '../meta'; +export const CHAT_GROUP_SESSION_ID_PREFIX = 'cg_' as const; + +export const isChatGroupSessionId = (id?: string | null): id is string => + typeof id === 'string' && id.startsWith(CHAT_GROUP_SESSION_ID_PREFIX); + export enum LobeSessionType { Agent = 'agent', Group = 'group', @@ -36,7 +41,7 @@ export interface LobeAgentSession { export interface LobeGroupSession { createdAt: Date; group?: string; - id: string; // Start with 'cg_' + id: string; // Start with CHAT_GROUP_SESSION_ID_PREFIX members?: GroupMemberWithAgent[]; meta: MetaData; pinned?: boolean; diff --git a/src/features/Electron/navigation/cachedData.ts b/src/features/Electron/navigation/cachedData.ts new file mode 100644 index 0000000000..6251110e15 --- /dev/null +++ b/src/features/Electron/navigation/cachedData.ts @@ -0,0 +1,85 @@ +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': { + 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' && 'topicId' in reference.params) { + const topicId = reference.params.topicId; + const topicDataMap = useChatStore.getState().topicDataMap; + for (const data of Object.values(topicDataMap)) { + const topic = data.items?.find((t) => t.id === topicId); + if (topic?.title) { + title = topic.title; + break; + } + } + } + + return { + avatar: meta.avatar, + backgroundColor: meta.backgroundColor, + title: title || '', + }; + } + + case 'group': + case 'group-topic': { + const groupId = 'groupId' in reference.params ? reference.params.groupId : undefined; + if (!groupId) return undefined; + + const group = sessionGroupSelectors.getGroupById(groupId)(useSessionStore.getState()); + if (!group) return undefined; + + let title = group.name; + if (reference.type === 'group-topic' && 'topicId' in reference.params) { + const topicId = reference.params.topicId; + const topicDataMap = useChatStore.getState().topicDataMap; + for (const data of Object.values(topicDataMap)) { + const topic = data.items?.find((t) => t.id === topicId); + if (topic?.title) { + title = topic.title; + break; + } + } + } + + return { title: title || '' }; + } + + case 'page': { + const pageId = 'pageId' in reference.params ? reference.params.pageId : undefined; + if (!pageId) return undefined; + + const document = listSelectors.getDocumentById(pageId)(usePageStore.getState()); + if (!document) return undefined; + + return { title: document.title || '' }; + } + + default: { + return undefined; + } + } +}; diff --git a/src/features/Electron/navigation/useNavigationHistory.ts b/src/features/Electron/navigation/useNavigationHistory.ts index f937be97cc..b9fc14970c 100644 --- a/src/features/Electron/navigation/useNavigationHistory.ts +++ b/src/features/Electron/navigation/useNavigationHistory.ts @@ -6,97 +6,11 @@ import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins'; -import { - type CachedPageData, - type PageReference, -} from '@/features/Electron/titlebar/RecentlyViewed/types'; -import { useAgentStore } from '@/store/agent'; -import { agentSelectors } from '@/store/agent/selectors/selectors'; -import { useChatStore } from '@/store/chat'; import { useElectronStore } from '@/store/electron'; -import { usePageStore } from '@/store/page'; -import { listSelectors } from '@/store/page/slices/list/selectors'; -import { useSessionStore } from '@/store/session'; -import { sessionGroupSelectors } from '@/store/session/slices/sessionGroup/selectors'; +import { getCachedDataForReference } from './cachedData'; import { getRouteMetadata } from './routeMetadata'; -/** - * Get cached display data for a page reference - */ -const getCachedDataForReference = (reference: PageReference): CachedPageData | undefined => { - switch (reference.type) { - case 'agent': - case 'agent-topic': { - const agentId = 'agentId' in reference.params ? reference.params.agentId : undefined; - if (!agentId) return undefined; - - const meta = agentSelectors.getAgentMetaById(agentId)(useAgentStore.getState()); - if (!meta || Object.keys(meta).length === 0) return undefined; - - // For agent-topic, try to get topic title - let title = meta.title; - if (reference.type === 'agent-topic' && 'topicId' in reference.params) { - const topicId = reference.params.topicId; - const topicDataMap = useChatStore.getState().topicDataMap; - for (const data of Object.values(topicDataMap)) { - const topic = data.items?.find((t) => t.id === topicId); - if (topic?.title) { - title = topic.title; - break; - } - } - } - - return { - avatar: meta.avatar, - backgroundColor: meta.backgroundColor, - title: title || '', - }; - } - - case 'group': - case 'group-topic': { - const groupId = 'groupId' in reference.params ? reference.params.groupId : undefined; - if (!groupId) return undefined; - - const group = sessionGroupSelectors.getGroupById(groupId)(useSessionStore.getState()); - if (!group) return undefined; - - // For group-topic, try to get topic title - let title = group.name; - if (reference.type === 'group-topic' && 'topicId' in reference.params) { - const topicId = reference.params.topicId; - const topicDataMap = useChatStore.getState().topicDataMap; - for (const data of Object.values(topicDataMap)) { - const topic = data.items?.find((t) => t.id === topicId); - if (topic?.title) { - title = topic.title; - break; - } - } - } - - return { title: title || '' }; - } - - case 'page': { - const pageId = 'pageId' in reference.params ? reference.params.pageId : undefined; - if (!pageId) return undefined; - - const document = listSelectors.getDocumentById(pageId)(usePageStore.getState()); - if (!document) return undefined; - - return { title: document.title || '' }; - } - - default: { - // Static pages don't need cached data - return undefined; - } - } -}; - /** * Hook to manage navigation history in Electron desktop app * Provides browser-like back/forward functionality diff --git a/src/features/Electron/navigation/useTabNavigation.ts b/src/features/Electron/navigation/useTabNavigation.ts new file mode 100644 index 0000000000..1ee7876f92 --- /dev/null +++ b/src/features/Electron/navigation/useTabNavigation.ts @@ -0,0 +1,79 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; + +import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins'; +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(); + + const activateTab = useElectronStore((s) => s.activateTab); + 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 prevLocationRef = useRef(null); + const loadedRef = useRef(false); + + // Load tabs from localStorage on mount + useEffect(() => { + if (!loadedRef.current) { + loadTabs(); + loadedRef.current = true; + } + }, [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 { tabs, activeTabId } = useElectronStore.getState(); + + // If this exact page is already a tab, activate it + const existing = tabs.find((t) => t.id === reference.id); + if (existing) { + 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); + } + }, [location.pathname, location.search, activateTab, updateTab]); + + // Update tab cache when dynamic title changes + useEffect(() => { + if (!currentPageTitle) return; + + const { tabs, activeTabId } = useElectronStore.getState(); + if (!activeTabId) return; + + const tab = tabs.find((t) => t.id === activeTabId); + if (!tab) return; + + if (tab.cached?.title === currentPageTitle) return; + + updateTabCache(activeTabId, { title: currentPageTitle }); + }, [currentPageTitle, updateTabCache]); +}; diff --git a/src/features/Electron/titlebar/NavigationBar.tsx b/src/features/Electron/titlebar/NavigationBar.tsx index 0846447e6c..5f80ef2c6f 100644 --- a/src/features/Electron/titlebar/NavigationBar.tsx +++ b/src/features/Electron/titlebar/NavigationBar.tsx @@ -7,6 +7,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useGlobalStore } from '@/store/global'; +import type { GlobalState } from '@/store/global/initialState'; import { systemStatusSelectors } from '@/store/global/selectors'; import { electronStylish } from '@/styles/electron'; import { isMacOS } from '@/utils/platform'; @@ -17,8 +18,14 @@ import { loadAllRecentlyViewedPlugins } from './RecentlyViewed/plugins'; const isMac = isMacOS(); +const navPanelSelector = (s: GlobalState) => { + const showLeftPanel = systemStatusSelectors.showLeftPanel(s); + if (!showLeftPanel) return 0; + return systemStatusSelectors.leftPanelWidth(s); +}; + const useNavPanelWidth = () => { - return useGlobalStore(systemStatusSelectors.leftPanelWidth); + return useGlobalStore(navPanelSelector); }; const styles = createStaticStyles(({ css, cssVar }) => ({ @@ -44,7 +51,7 @@ const NavigationBar = memo(() => { const { t } = useTranslation('electron'); const { canGoBack, canGoForward, goBack, goForward } = useNavigationHistory(); const [historyOpen, setHistoryOpen] = useState(false); - // Use ResizeObserver for real-time width updates during resize + const leftPanelWidth = useNavPanelWidth(); // Toggle history popover @@ -71,13 +78,18 @@ const NavigationBar = memo(() => { // Tooltip content for the clock button const tooltipContent = t('navigation.recentView'); + const isLeftPanelVisible = leftPanelWidth > 0; + return ( diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/agentPlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/agentPlugin.ts index 6e8cbe55e3..e40cd7bf89 100644 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/agentPlugin.ts +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/agentPlugin.ts @@ -1,5 +1,7 @@ import { MessageSquare } from 'lucide-react'; +import { useChatStore } from '@/store/chat'; + import { type AgentParams, type PageReference, type ResolvedPageData } from '../types'; import { type PluginContext, type RecentlyViewedPlugin } from './types'; import { createPageReference } from './types'; @@ -28,6 +30,10 @@ export const agentPlugin: RecentlyViewedPlugin<'agent'> = { 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; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/agentTopicPlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/agentTopicPlugin.ts index d797c0109b..60e219aaa8 100644 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/agentTopicPlugin.ts +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/agentTopicPlugin.ts @@ -1,5 +1,7 @@ import { MessageSquare } from 'lucide-react'; +import { useChatStore } from '@/store/chat'; + import { type AgentTopicParams, type PageReference, type ResolvedPageData } from '../types'; import { type PluginContext, type RecentlyViewedPlugin } from './types'; import { createPageReference } from './types'; @@ -36,6 +38,10 @@ export const agentTopicPlugin: RecentlyViewedPlugin<'agent-topic'> = { return 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 match = pathname.match(AGENT_PATH_REGEX); if (!match) return null; @@ -56,6 +62,7 @@ export const agentTopicPlugin: RecentlyViewedPlugin<'agent-topic'> = { 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; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/registry.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/registry.ts index 7b43a1db92..7720ff8603 100644 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/registry.ts +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/registry.ts @@ -216,6 +216,15 @@ class PluginRegistry { 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); + } + /** * Resolve multiple page references, filtering out non-existent ones */ diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/types.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/types.ts index ee62afb860..df7091e4d0 100644 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/types.ts +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/types.ts @@ -72,6 +72,12 @@ export interface BaseRecentlyViewedPlugin { */ 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 */ @@ -125,6 +131,12 @@ export interface RecentlyViewedPlugin { */ matchUrl: (pathname: string, searchParams: URLSearchParams) => boolean; + /** + * Called when a tab with this reference type is activated. + * Use to perform store-level state transitions (e.g. switchTopic). + */ + onActivate?: (reference: PageReference) => void; + /** * Parse URL into a page reference * Returns null if URL doesn't match diff --git a/src/features/Electron/titlebar/TabBar/TabItem.tsx b/src/features/Electron/titlebar/TabBar/TabItem.tsx new file mode 100644 index 0000000000..ba679a99f2 --- /dev/null +++ b/src/features/Electron/titlebar/TabBar/TabItem.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { ActionIcon, ContextMenuTrigger, Flexbox, type GenericItemType, Icon } from '@lobehub/ui'; +import { cx } from 'antd-style'; +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 { useStyles } from './styles'; + +interface TabItemProps { + index: number; + isActive: boolean; + item: ResolvedPageData; + onActivate: (id: string, url: string) => void; + onClose: (id: string) => void; + onCloseLeft: (id: string) => void; + onCloseOthers: (id: string) => void; + onCloseRight: (id: string) => void; + totalCount: number; +} + +const TabItem = memo( + ({ + item, + isActive, + index, + totalCount, + onActivate, + onClose, + onCloseOthers, + onCloseLeft, + onCloseRight, + }) => { + const styles = useStyles; + const { t } = useTranslation('electron'); + const id = item.reference.id; + + const handleClick = useCallback(() => { + if (!isActive) { + onActivate(id, item.url); + } + }, [isActive, onActivate, id, item.url]); + + const handleClose = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onClose(id); + }, + [onClose, id], + ); + + const contextMenuItems = useCallback( + (): GenericItemType[] => [ + { + key: 'closeCurrentTab', + label: t('tab.closeCurrentTab'), + onClick: () => onClose(id), + }, + { + key: 'closeOtherTabs', + label: t('tab.closeOtherTabs'), + onClick: () => onCloseOthers(id), + }, + { type: 'divider' }, + { + disabled: index === 0, + key: 'closeLeftTabs', + label: t('tab.closeLeftTabs'), + onClick: () => onCloseLeft(id), + }, + { + disabled: index === totalCount - 1, + key: 'closeRightTabs', + label: t('tab.closeRightTabs'), + onClick: () => onCloseRight(id), + }, + ], + [t, id, index, totalCount, onClose, onCloseOthers, onCloseLeft, onCloseRight], + ); + + return ( + + + {item.icon && } + {item.title} + + + + ); + }, +); + +TabItem.displayName = 'TabItem'; + +export default TabItem; diff --git a/src/features/Electron/titlebar/TabBar/hooks/useResolvedTabs.ts b/src/features/Electron/titlebar/TabBar/hooks/useResolvedTabs.ts new file mode 100644 index 0000000000..2d513218ed --- /dev/null +++ b/src/features/Electron/titlebar/TabBar/hooks/useResolvedTabs.ts @@ -0,0 +1,35 @@ +'use client'; + +import { useMemo } from 'react'; + +import { useElectronStore } from '@/store/electron'; + +import { usePluginContext } from '../../RecentlyViewed/hooks/usePluginContext'; +import { pluginRegistry } from '../../RecentlyViewed/plugins'; +import { type ResolvedPageData } from '../../RecentlyViewed/types'; + +interface UseResolvedTabsResult { + activeTabId: string | null; + tabs: ResolvedPageData[]; +} + +export const useResolvedTabs = (): UseResolvedTabsResult => { + const ctx = usePluginContext(); + + const tabRefs = useElectronStore((s) => s.tabs); + const activeTabId = useElectronStore((s) => s.activeTabId); + + 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]); + + return { activeTabId, tabs }; +}; diff --git a/src/features/Electron/titlebar/TabBar/index.tsx b/src/features/Electron/titlebar/TabBar/index.tsx new file mode 100644 index 0000000000..0ae885c58a --- /dev/null +++ b/src/features/Electron/titlebar/TabBar/index.tsx @@ -0,0 +1,148 @@ +'use client'; + +import { ScrollArea } from '@lobehub/ui'; +import { startTransition, useCallback, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins'; +import { useElectronStore } from '@/store/electron'; +import { electronStylish } from '@/styles/electron'; + +import { useResolvedTabs } from './hooks/useResolvedTabs'; +import { useStyles } from './styles'; +import TabItem from './TabItem'; + +const TAB_WIDTH = 180; +const TAB_GAP = 2; + +const TabBar = () => { + const styles = useStyles; + const navigate = useNavigate(); + const viewportRef = useRef(null); + const { tabs, activeTabId } = useResolvedTabs(); + const activateTab = useElectronStore((s) => s.activateTab); + const removeTab = useElectronStore((s) => s.removeTab); + const closeOtherTabs = useElectronStore((s) => s.closeOtherTabs); + const closeLeftTabs = useElectronStore((s) => s.closeLeftTabs); + const closeRightTabs = useElectronStore((s) => s.closeRightTabs); + + const handleActivate = useCallback( + (id: string, url: string) => { + // 优先更新 Tab 激活状态(高优先级) + activateTab(id); + const tab = tabs.find((t) => t.reference.id === id); + if (tab) pluginRegistry.onActivate(tab.reference); + // 路由跳转降级为 startTransition(低优先级) + startTransition(() => navigate(url)); + }, + [activateTab, navigate, tabs], + ); + + 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); + } + } else { + navigate('/'); + } + }, [tabs, navigate]); + + const handleClose = useCallback( + (id: string) => { + const isActive = id === activeTabId; + const nextActiveId = removeTab(id); + + startTransition(() => { + if (isActive && nextActiveId) { + const nextTab = tabs.find((t) => t.reference.id === nextActiveId); + if (nextTab) { + navigate(nextTab.url); + } + } + + if (!nextActiveId) { + navigate('/'); + } + }); + }, + [activeTabId, removeTab, tabs, navigate], + ); + + const handleCloseOthers = useCallback( + (id: string) => { + closeOtherTabs(id); + startTransition(() => { + const target = tabs.find((t) => t.reference.id === id); + if (target) navigate(target.url); + }); + }, + [closeOtherTabs, tabs, navigate], + ); + + const handleCloseLeft = useCallback( + (id: string) => { + closeLeftTabs(id); + startTransition(() => navigateToActive()); + }, + [closeLeftTabs, navigateToActive], + ); + + const handleCloseRight = useCallback( + (id: string) => { + closeRightTabs(id); + startTransition(() => navigateToActive()); + }, + [closeRightTabs, navigateToActive], + ); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport || !activeTabId) return; + + const activeIndex = tabs.findIndex((t) => t.reference.id === activeTabId); + if (activeIndex < 0) return; + + const tabLeft = activeIndex * (TAB_WIDTH + TAB_GAP); + const tabRight = tabLeft + TAB_WIDTH; + const { scrollLeft, clientWidth } = viewport; + + if (tabLeft < scrollLeft) { + viewport.scrollLeft = tabLeft; + } else if (tabRight > scrollLeft + clientWidth) { + viewport.scrollLeft = tabRight - clientWidth; + } + }, [activeTabId, tabs]); + + if (tabs.length < 2) return null; + + return ( + + {tabs.map((tab, index) => ( + + ))} + + ); +}; + +export default TabBar; diff --git a/src/features/Electron/titlebar/TabBar/storage.ts b/src/features/Electron/titlebar/TabBar/storage.ts new file mode 100644 index 0000000000..c053469421 --- /dev/null +++ b/src/features/Electron/titlebar/TabBar/storage.ts @@ -0,0 +1,50 @@ +import { type PageReference } from '@/features/Electron/titlebar/RecentlyViewed/types'; + +export const TAB_PAGES_STORAGE_KEY = 'lobechat:desktop:tab-pages:v1'; + +interface TabPagesStorageData { + activeTabId: string | null; + tabs: PageReference[]; +} + +export const getTabPages = (): TabPagesStorageData => { + if (typeof window === 'undefined') return { activeTabId: null, tabs: [] }; + + try { + const data = window.localStorage.getItem(TAB_PAGES_STORAGE_KEY); + if (!data) return { activeTabId: null, tabs: [] }; + + const parsed = JSON.parse(data); + if (!parsed || typeof parsed !== 'object') return { activeTabId: null, tabs: [] }; + + 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, + ) + : []; + + return { + activeTabId: typeof parsed.activeTabId === 'string' ? parsed.activeTabId : null, + tabs, + }; + } catch { + return { activeTabId: null, tabs: [] }; + } +}; + +export const saveTabPages = (tabs: PageReference[], activeTabId: string | null): boolean => { + if (typeof window === 'undefined') return false; + + try { + window.localStorage.setItem(TAB_PAGES_STORAGE_KEY, JSON.stringify({ activeTabId, tabs })); + return true; + } catch { + return false; + } +}; diff --git a/src/features/Electron/titlebar/TabBar/styles.ts b/src/features/Electron/titlebar/TabBar/styles.ts new file mode 100644 index 0000000000..98004ccd58 --- /dev/null +++ b/src/features/Electron/titlebar/TabBar/styles.ts @@ -0,0 +1,72 @@ +import { createStaticStyles } from 'antd-style'; + +export const useStyles = createStaticStyles(({ css, cssVar }) => ({ + closeIcon: css` + flex-shrink: 0; + color: ${cssVar.colorTextTertiary}; + opacity: 0; + transition: opacity 0.15s ${cssVar.motionEaseOut}; + + &:hover { + color: ${cssVar.colorText}; + } + `, + container: css` + flex: 1; + min-width: 0; + border-radius: 0; + background: transparent; + `, + tab: css` + cursor: default; + user-select: none; + + position: relative; + + overflow: hidden; + flex-shrink: 0; + + width: 180px; + padding-block: 2px; + padding-inline: 10px; + border-radius: ${cssVar.borderRadiusSM}; + + font-size: 12px; + + background-color: ${cssVar.colorFillTertiary}; + + transition: background-color 0.15s ${cssVar.motionEaseInOut}; + + &:hover { + background-color: ${cssVar.colorFillSecondary}; + } + + &:hover .closeIcon { + opacity: 1; + } + `, + tabActive: css` + background-color: ${cssVar.colorFillSecondary}; + + &:hover { + background-color: ${cssVar.colorFill}; + } + + & .closeIcon { + opacity: 1; + } + `, + tabIcon: css` + flex-shrink: 0; + color: ${cssVar.colorTextSecondary}; + `, + tabTitle: css` + overflow: hidden; + flex: 1; + + font-size: 12px; + color: ${cssVar.colorText}; + text-overflow: ellipsis; + white-space: nowrap; + `, +})); diff --git a/src/features/Electron/titlebar/TitleBar.tsx b/src/features/Electron/titlebar/TitleBar.tsx index d580747345..2bb71be693 100644 --- a/src/features/Electron/titlebar/TitleBar.tsx +++ b/src/features/Electron/titlebar/TitleBar.tsx @@ -8,9 +8,11 @@ import { electronStylish } from '@/styles/electron'; import { isMacOS } from '@/utils/platform'; import Connection from '../connection/Connection'; +import { useTabNavigation } from '../navigation/useTabNavigation'; import { useWatchThemeUpdate } from '../system/useWatchThemeUpdate'; import { UpdateNotification } from '../updater/UpdateNotification'; import NavigationBar from './NavigationBar'; +import TabBar from './TabBar'; import WinControl from './WinControl'; const isMac = isMacOS(); @@ -23,6 +25,7 @@ const TitleBar = memo(() => { initElectronAppState(); useWatchThemeUpdate(); + useTabNavigation(); const showWinControl = isAppStateInit && !isMac; @@ -45,6 +48,7 @@ const TitleBar = memo(() => { width={'100%'} > + diff --git a/src/features/PageEditor/Copilot/AgentSelector/AgentSelectorAction.tsx b/src/features/PageEditor/Copilot/AgentSelector/AgentSelectorAction.tsx index 0b78efb315..65cc180123 100644 --- a/src/features/PageEditor/Copilot/AgentSelector/AgentSelectorAction.tsx +++ b/src/features/PageEditor/Copilot/AgentSelector/AgentSelectorAction.tsx @@ -4,6 +4,7 @@ import { ChevronsUpDownIcon } from 'lucide-react'; import { memo, Suspense, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { conversationSelectors, useConversationStore } from '@/features/Conversation'; import SkeletonList from '@/features/NavPanel/components/SkeletonList'; import { useFetchAgentList } from '@/hooks/useFetchAgentList'; import AgentAvatar from '@/routes/(main)/home/_layout/Body/Agent/List/AgentItem/Avatar'; @@ -34,13 +35,13 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ })); interface AgentSelectorActionProps { - agentId: string; onAgentChange: (id: string) => void; } -const AgentSelectorAction = memo(({ agentId, onAgentChange }) => { +const AgentSelectorAction = memo(({ onAgentChange }) => { const { t } = useTranslation(['chat', 'common']); const [open, setOpen] = useState(false); + const agentId = useConversationStore(conversationSelectors.agentId); const agents = useHomeStore(homeAgentListSelectors.allAgents); const isAgentListInit = useHomeStore(homeAgentListSelectors.isAgentListInit); @@ -50,7 +51,9 @@ const AgentSelectorAction = memo(({ agentId, onAgentCh useFetchAgentList(); const agentsWithBuiltin = useMemo(() => { - const hasPageAgent = agents.some((agent) => agent.id === pageAgentId); + // Page Copilot only supports selecting agent sessions, not group sessions. + const availableAgents = agents.filter((agent) => agent.type === 'agent'); + const hasPageAgent = availableAgents.some((agent) => agent.id === pageAgentId); if (pageAgentId && !hasPageAgent) { return [ @@ -63,11 +66,11 @@ const AgentSelectorAction = memo(({ agentId, onAgentCh type: 'agent' as const, updatedAt: new Date(), }, - ...agents, + ...availableAgents, ]; } - return agents; + return availableAgents; }, [agents, pageAgentId, pageAgentData, t]); const activeAgent = useMemo( diff --git a/src/features/PageEditor/Copilot/Conversation.tsx b/src/features/PageEditor/Copilot/Conversation.tsx index 3e81f427f2..90969eec23 100644 --- a/src/features/PageEditor/Copilot/Conversation.tsx +++ b/src/features/PageEditor/Copilot/Conversation.tsx @@ -1,10 +1,16 @@ +import { isChatGroupSessionId } from '@lobechat/types'; import { Flexbox } from '@lobehub/ui'; import { memo, useCallback, useEffect, useMemo } from 'react'; import DragUploadZone, { useUploadFiles } from '@/components/DragUploadZone'; import { actionMap } from '@/features/ChatInput/ActionBar/config'; import { ActionBarContext } from '@/features/ChatInput/ActionBar/context'; -import { ChatInput, ChatList } from '@/features/Conversation'; +import { + ChatInput, + ChatList, + conversationSelectors, + useConversationStore, +} from '@/features/Conversation'; import { useAgentStore } from '@/store/agent'; import { agentByIdSelectors } from '@/store/agent/selectors'; import { useChatStore } from '@/store/chat'; @@ -23,23 +29,31 @@ const COMPACT_CONTEXT_VALUE = { actionSize: COMPACT_ACTION_SIZE }; const COMPACT_ACTION_BAR_STYLE = { paddingLeft: 4, paddingRight: 4 }; const COMPACT_SEND_BUTTON_PROPS = { size: 28 }; -interface ConversationProps { - agentId: string; -} - -const Conversation = memo(({ agentId }) => { - const [activeAgentId, setActiveAgentId, useFetchAgentConfig] = useAgentStore((s) => [ - s.activeAgentId, +const Conversation = memo(() => { + const [setActiveAgentId, useFetchAgentConfig] = useAgentStore((s) => [ s.setActiveAgentId, s.useFetchAgentConfig, ]); + const currentAgentId = useConversationStore(conversationSelectors.agentId); useEffect(() => { - setActiveAgentId(agentId); - useChatStore.setState({ activeAgentId: agentId }); - }, [agentId, setActiveAgentId]); + if (!currentAgentId) return; - const currentAgentId = activeAgentId || agentId; + if (useAgentStore.getState().activeAgentId !== currentAgentId) { + setActiveAgentId(currentAgentId); + } + + const { activeAgentId, activeTopicId, switchTopic } = useChatStore.getState(); + + if (activeAgentId !== currentAgentId) { + useChatStore.setState({ activeAgentId: currentAgentId }); + } + + // Reset topic on agent/context switch to avoid reusing old topic scope. + if (activeAgentId !== currentAgentId || !!activeTopicId) { + void switchTopic(null, { scope: 'page', skipRefreshMessage: true }); + } + }, [currentAgentId, setActiveAgentId]); useFetchAgentConfig(true, currentAgentId); @@ -51,28 +65,25 @@ const Conversation = memo(({ agentId }) => { const handleAgentChange = useCallback( (id: string) => { + if (!id || id === currentAgentId || isChatGroupSessionId(id)) return; setActiveAgentId(id); - useChatStore.setState({ activeAgentId: id }); }, - [setActiveAgentId], + [currentAgentId, setActiveAgentId], ); const leftContent = useMemo( () => ( - + ), - [currentAgentId, handleAgentChange], + [handleAgentChange], ); - const modelSelector = useMemo( - () => , - [currentAgentId], - ); + const modelSelector = useMemo(() => , []); return ( (({ agentId }) => { onUploadFiles={handleUploadFiles} > - + } /> diff --git a/src/features/PageEditor/Copilot/CopilotModelSelector.tsx b/src/features/PageEditor/Copilot/CopilotModelSelector.tsx index 1f3a2b72cd..cd6e31608c 100644 --- a/src/features/PageEditor/Copilot/CopilotModelSelector.tsx +++ b/src/features/PageEditor/Copilot/CopilotModelSelector.tsx @@ -4,6 +4,7 @@ import { ChevronDownIcon, Settings2Icon } from 'lucide-react'; import { memo, useCallback, useState } from 'react'; import ActionPopover from '@/features/ChatInput/ActionBar/components/ActionPopover'; +import { conversationSelectors, useConversationStore } from '@/features/Conversation'; import ModelSwitchPanel from '@/features/ModelSwitchPanel'; import ControlsForm from '@/features/ModelSwitchPanel/components/ControlsForm'; import { useAgentStore } from '@/store/agent'; @@ -35,12 +36,9 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ `, })); -interface CopilotModelSelectorProps { - agentId: string; -} - -const CopilotModelSelector = memo(({ agentId }) => { +const CopilotModelSelector = memo(() => { const [settingsOpen, setSettingsOpen] = useState(false); + const agentId = useConversationStore(conversationSelectors.agentId); const [model, provider, updateAgentConfigById] = useAgentStore((s) => [ agentByIdSelectors.getAgentModelById(agentId)(s), diff --git a/src/features/PageEditor/Copilot/Toolbar.tsx b/src/features/PageEditor/Copilot/Toolbar.tsx index ce93869133..1c59483a43 100644 --- a/src/features/PageEditor/Copilot/Toolbar.tsx +++ b/src/features/PageEditor/Copilot/Toolbar.tsx @@ -4,6 +4,7 @@ import { memo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { DESKTOP_HEADER_ICON_SIZE } from '@/const/layoutTokens'; +import { conversationSelectors, useConversationStore } from '@/features/Conversation'; import NavHeader from '@/features/NavHeader'; import { useChatStore } from '@/store/chat'; import { topicSelectors } from '@/store/chat/slices/topic/selectors'; @@ -11,13 +12,10 @@ import { useGlobalStore } from '@/store/global'; import TopicItem from './TopicSelector/TopicItem'; -interface CopilotToolbarProps { - agentId: string; -} - -const CopilotToolbar = memo(({ agentId }) => { +const CopilotToolbar = memo(() => { const { t } = useTranslation('topic'); const [topicPopoverOpen, setTopicPopoverOpen] = useState(false); + const agentId = useConversationStore(conversationSelectors.agentId); useChatStore((s) => s.useFetchTopics)(true, { agentId }); diff --git a/src/features/PageEditor/Copilot/index.tsx b/src/features/PageEditor/Copilot/index.tsx index 93334b9b7f..5abcdbc98c 100644 --- a/src/features/PageEditor/Copilot/index.tsx +++ b/src/features/PageEditor/Copilot/index.tsx @@ -3,8 +3,6 @@ import { memo } from 'react'; import RightPanel from '@/features/RightPanel'; -import { useAgentStore } from '@/store/agent'; -import { builtinAgentSelectors } from '@/store/agent/selectors'; import { useGlobalStore } from '@/store/global'; import { systemStatusSelectors } from '@/store/global/selectors'; @@ -14,7 +12,6 @@ import Conversation from './Conversation'; * Help write, read, and edit the page */ const Copilot = memo(() => { - const pageAgentId = useAgentStore(builtinAgentSelectors.pageAgentId); const [width, updateSystemStatus] = useGlobalStore((s) => [ systemStatusSelectors.pageAgentPanelWidth(s), s.updateSystemStatus, @@ -30,7 +27,7 @@ const Copilot = memo(() => { } }} > - + ); }); diff --git a/src/features/PageEditor/PageAgentProvider.tsx b/src/features/PageEditor/PageAgentProvider.tsx index ecec77fbf2..5ca8d6c40b 100644 --- a/src/features/PageEditor/PageAgentProvider.tsx +++ b/src/features/PageEditor/PageAgentProvider.tsx @@ -1,30 +1,33 @@ +import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents'; +import { isChatGroupSessionId } from '@lobechat/types'; import { type ReactNode } from 'react'; import { memo, useMemo } from 'react'; +import Loading from '@/components/Loading/BrandTextLoading'; import { ConversationProvider } from '@/features/Conversation'; import { useOperationState } from '@/hooks/useOperationState'; import { useAgentStore } from '@/store/agent'; +import { builtinAgentSelectors } from '@/store/agent/selectors'; import { useChatStore } from '@/store/chat'; import { type MessageMapKeyInput } from '@/store/chat/utils/messageMapKey'; import { messageMapKey } from '@/store/chat/utils/messageMapKey'; interface PageAgentProviderProps { children: ReactNode; - pageAgentId: string; } -const PageAgentProvider = memo(({ pageAgentId, children }) => { - const activeTopicId = useChatStore((s) => s.activeTopicId); - const [activeAgentId, agentMap] = useAgentStore((s) => [s.activeAgentId, s.agentMap]); - // Build conversation context for page agent - // Using topic dimension for message management (1 agent can have multiple topics) - // Use activeAgentId only if it exists in agentMap (is loaded), otherwise fall back to pageAgentId - const selectedAgentId = useMemo(() => { - if (activeAgentId && agentMap[activeAgentId]) { - return activeAgentId; - } - return pageAgentId; - }, [activeAgentId, agentMap, pageAgentId]); +export const PageAgentProvider = memo(({ children }) => { + const useInitBuiltinAgent = useAgentStore((s) => s.useInitBuiltinAgent); + const pageAgentId = useAgentStore(builtinAgentSelectors.pageAgentId); + const activeTopicId = useChatStore((s) => s.activeTopicId); + const activeAgentId = useAgentStore((s) => s.activeAgentId); + + useInitBuiltinAgent(BUILTIN_AGENT_SLUGS.pageAgent); + + // Build conversation context for page agent. + // Ignore chat-group ids in page scope and fall back to page agent. + const selectedAgentId = + !activeAgentId || isChatGroupSessionId(activeAgentId) ? pageAgentId : activeAgentId; const context = useMemo( () => ({ @@ -36,16 +39,15 @@ const PageAgentProvider = memo(({ pageAgentId, children ); // Get messages from ChatStore based on context - const chatKey = useMemo( - () => (context ? messageMapKey(context) : null), - [context?.agentId, context?.topicId], - ); + const chatKey = useMemo(() => messageMapKey(context), [context]); const replaceMessages = useChatStore((s) => s.replaceMessages); const messages = useChatStore((s) => (chatKey ? s.dbMessagesMap[chatKey] : undefined)); // Get operation state for reactive updates const operationState = useOperationState(context); + if (!pageAgentId) return ; + return ( (({ pageAgentId, children ); }); - -export default PageAgentProvider; diff --git a/src/features/PageEditor/PageEditor.tsx b/src/features/PageEditor/PageEditor.tsx index 8adbf13115..b5d789e5db 100644 --- a/src/features/PageEditor/PageEditor.tsx +++ b/src/features/PageEditor/PageEditor.tsx @@ -1,25 +1,21 @@ 'use client'; -import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents'; import { EditorProvider } from '@lobehub/editor/react'; import { Flexbox } from '@lobehub/ui'; import { cssVar } from 'antd-style'; import type { FC } from 'react'; import { memo } from 'react'; -import Loading from '@/components/Loading/BrandTextLoading'; import DiffAllToolbar from '@/features/EditorCanvas/DiffAllToolbar'; import WideScreenContainer from '@/features/WideScreenContainer'; import { useRegisterFilesHotkeys } from '@/hooks/useHotkeys'; -import { useAgentStore } from '@/store/agent'; -import { builtinAgentSelectors } from '@/store/agent/selectors'; import { usePageStore } from '@/store/page'; import { StyleSheet } from '@/utils/styles'; import Copilot from './Copilot'; import EditorCanvas from './EditorCanvas'; import Header from './Header'; -import PageAgentProvider from './PageAgentProvider'; +import { PageAgentProvider } from './PageAgentProvider'; import { PageEditorProvider } from './PageEditorProvider'; import PageTitle from './PageTitle'; import { usePageEditorStore } from './store'; @@ -104,17 +100,10 @@ export const PageEditor: FC = ({ title, emoji, }) => { - const useInitBuiltinAgent = useAgentStore((s) => s.useInitBuiltinAgent); - const pageAgentId = useAgentStore(builtinAgentSelectors.pageAgentId); - - useInitBuiltinAgent(BUILTIN_AGENT_SLUGS.pageAgent); - const deletePage = usePageStore((s) => s.deletePage); - if (!pageAgentId) return ; - return ( - + (({ pageId, className }) => { const selectPage = usePageStore((s) => s.selectPage); const setRenamingPageId = usePageStore((s) => s.setRenamingPageId); + const addTab = useElectronStore((s) => s.addTab); const active = selectedPageId === pageId; const title = document?.title || t('pageList.untitled'); @@ -37,17 +41,39 @@ const PageListItem = memo(({ pageId, className }) => { [pageId, setRenamingPageId], ); + const clickTimerRef = useRef | null>(null); + const handleClick = useCallback( (e: MouseEvent) => { // Skip navigation in current tab when opening in new tab if (e.metaKey || e.ctrlKey) return; if (!editing) { - selectPage(pageId); + if (isDesktop) { + clickTimerRef.current = setTimeout(() => { + clickTimerRef.current = null; + selectPage(pageId); + }, 250); + } else { + selectPage(pageId); + } } }, [editing, selectPage, pageId], ); + const handleDoubleClick = useCallback(() => { + if (!isDesktop) return; + if (clickTimerRef.current) { + clearTimeout(clickTimerRef.current); + clickTimerRef.current = null; + } + const reference = pluginRegistry.parseUrl(`/page/${pageId}`, ''); + if (reference) { + addTab(reference); + selectPage(pageId); + } + }, [pageId, addTab, selectPage]); + // Icon with emoji support const icon = useMemo(() => { if (emoji) { @@ -71,6 +97,7 @@ const PageListItem = memo(({ pageId, className }) => { key={pageId} title={title} onClick={handleClick} + onDoubleClick={handleDoubleClick} /> MenuProps['items']) => { const { t } = useTranslation(['common', 'file']); const { message, modal } = App.useApp(); + const navigate = useNavigate(); + const addTab = useElectronStore((s) => s.addTab); const removePage = usePageStore((s) => s.removePage); const duplicatePage = usePageStore((s) => s.duplicatePage); @@ -51,6 +57,24 @@ export const useDropdownMenu = ({ return useCallback( () => [ + ...(isDesktop + ? [ + { + icon: , + key: 'openInNewTab', + label: t('pageList.actions.openInNewTab', { ns: 'file' }), + onClick: () => { + const url = `/page/${pageId}`; + const reference = pluginRegistry.parseUrl(url, ''); + if (reference) { + addTab(reference); + navigate(url); + } + }, + }, + { type: 'divider' as const }, + ] + : []), { icon: , key: 'rename', @@ -72,6 +96,6 @@ export const useDropdownMenu = ({ onClick: handleDelete, }, ].filter(Boolean) as MenuProps['items'], - [t, toggleEditing, handleDuplicate, handleDelete], + [t, toggleEditing, handleDuplicate, handleDelete, pageId, addTab, navigate], ); }; diff --git a/src/features/ResourceManager/components/Editor/FileCopilot.tsx b/src/features/ResourceManager/components/Editor/FileCopilot.tsx index e4ffe3dbac..694026d8e1 100644 --- a/src/features/ResourceManager/components/Editor/FileCopilot.tsx +++ b/src/features/ResourceManager/components/Editor/FileCopilot.tsx @@ -5,10 +5,15 @@ import { memo, useEffect } from 'react'; import DragUploadZone, { useUploadFiles } from '@/components/DragUploadZone'; import { type ActionKeys } from '@/features/ChatInput'; -import { ChatInput, ChatList } from '@/features/Conversation'; +import { + ChatInput, + ChatList, + conversationSelectors, + useConversationStore, +} from '@/features/Conversation'; import RightPanel from '@/features/RightPanel'; import { useAgentStore } from '@/store/agent'; -import { agentByIdSelectors, builtinAgentSelectors } from '@/store/agent/selectors'; +import { agentByIdSelectors } from '@/store/agent/selectors'; import { useChatStore } from '@/store/chat'; const actions: ActionKeys[] = ['model', 'search']; @@ -17,20 +22,33 @@ const actions: ActionKeys[] = ['model', 'search']; * Help analyze and work with files */ const FileCopilot = memo(() => { - const pageAgentId = useAgentStore(builtinAgentSelectors.pageAgentId); - const [activeAgentId, setActiveAgentId, useFetchAgentConfig] = useAgentStore((s) => [ - s.activeAgentId, + const [setActiveAgentId, useFetchAgentConfig] = useAgentStore((s) => [ s.setActiveAgentId, s.useFetchAgentConfig, ]); + const currentAgentId = useConversationStore(conversationSelectors.agentId); useEffect(() => { - setActiveAgentId(pageAgentId); - // Also set the chat store's activeAgentId so topic selectors can work correctly - useChatStore.setState({ activeAgentId: pageAgentId }); - }, [pageAgentId, setActiveAgentId]); + if (!currentAgentId) return; - const currentAgentId = activeAgentId || pageAgentId; + if (useAgentStore.getState().activeAgentId !== currentAgentId) { + setActiveAgentId(currentAgentId); + } + + const { activeAgentId, activeTopicId, switchTopic } = useChatStore.getState(); + + if (activeAgentId !== currentAgentId) { + useChatStore.setState( + { activeAgentId: currentAgentId }, + false, + 'ResourceManager/FileCopilot/syncActiveAgentId', + ); + } + + if (activeAgentId !== currentAgentId || !!activeTopicId) { + void switchTopic(null, { scope: 'page', skipRefreshMessage: true }); + } + }, [currentAgentId, setActiveAgentId]); // Fetch agent config when activeAgentId changes to ensure it's loaded in the store useFetchAgentConfig(true, currentAgentId); diff --git a/src/features/ResourceManager/components/Editor/index.tsx b/src/features/ResourceManager/components/Editor/index.tsx index 82115e63c6..5caa25f04c 100644 --- a/src/features/ResourceManager/components/Editor/index.tsx +++ b/src/features/ResourceManager/components/Editor/index.tsx @@ -1,6 +1,5 @@ 'use client'; -import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents'; import { ActionIcon, Flexbox } from '@lobehub/ui'; import { Modal } from 'antd'; import { cssVar, useTheme } from 'antd-style'; @@ -8,13 +7,10 @@ import { ArrowLeftIcon, DownloadIcon, InfoIcon } from 'lucide-react'; import { memo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import Loading from '@/components/Loading/BrandTextLoading'; import NavHeader from '@/features/NavHeader'; -import PageAgentProvider from '@/features/PageEditor/PageAgentProvider'; +import { PageAgentProvider } from '@/features/PageEditor/PageAgentProvider'; import FileDetailComponent from '@/routes/(main)/resource/features/FileDetail'; import { useResourceManagerStore } from '@/routes/(main)/resource/features/store'; -import { useAgentStore } from '@/store/agent'; -import { builtinAgentSelectors } from '@/store/agent/selectors'; import { fileManagerSelectors, useFileStore } from '@/store/file'; import { downloadFile } from '@/utils/client/downloadFile'; @@ -116,15 +112,8 @@ FileEditorCanvas.displayName = 'FileEditorCanvas'; * So we depend on context, not props. */ const FileEditor = memo(({ onBack }) => { - const useInitBuiltinAgent = useAgentStore((s) => s.useInitBuiltinAgent); - const pageAgentId = useAgentStore(builtinAgentSelectors.pageAgentId); - - useInitBuiltinAgent(BUILTIN_AGENT_SLUGS.pageAgent); - - if (!pageAgentId) return ; - return ( - + ); diff --git a/src/locales/default/electron.ts b/src/locales/default/electron.ts index e951592441..5bb3bffbdc 100644 --- a/src/locales/default/electron.ts +++ b/src/locales/default/electron.ts @@ -28,6 +28,10 @@ export default { 'navigation.settings': 'Settings', 'navigation.unpin': 'Unpin', 'notification.finishChatGeneration': 'AI message generation completed', + 'tab.closeCurrentTab': 'Close Tab', + 'tab.closeLeftTabs': 'Close Tabs to the Left', + 'tab.closeOtherTabs': 'Close Other Tabs', + 'tab.closeRightTabs': 'Close Tabs to the Right', 'proxy.auth': 'Authentication Required', 'proxy.authDesc': 'If the proxy server requires a username and password', 'proxy.authSettings': 'Authentication Settings', diff --git a/src/locales/default/file.ts b/src/locales/default/file.ts index cc50a2e66f..cfaef7a9e2 100644 --- a/src/locales/default/file.ts +++ b/src/locales/default/file.ts @@ -109,6 +109,7 @@ export default { 'pageEditor.saving': 'Saving...', 'pageEditor.titlePlaceholder': 'Untitled', 'pageEditor.wordCount': '{{wordCount}} words', + 'pageList.actions.openInNewTab': 'Open in New Tab', 'pageList.copyContent': 'Copy Full Text', 'pageList.duplicate': 'Duplicate', 'pageList.empty': 'No pages yet. Click the button above to create your first one.', diff --git a/src/locales/default/topic.ts b/src/locales/default/topic.ts index 90a687fbdc..5be2d1d485 100644 --- a/src/locales/default/topic.ts +++ b/src/locales/default/topic.ts @@ -8,6 +8,7 @@ export default { 'actions.duplicate': 'Duplicate', 'actions.export': 'Export Topics', 'actions.import': 'Import Conversation', + 'actions.openInNewTab': 'Open in New Tab', 'actions.openInNewWindow': 'Open in a new window', 'actions.removeAll': 'Delete All Topics', 'actions.removeUnstarred': 'Delete Unstarred Topics', diff --git a/src/routes/(main)/agent/_layout/AgentIdSync.tsx b/src/routes/(main)/agent/_layout/AgentIdSync.tsx index 6a541da5e8..0edb2827c8 100644 --- a/src/routes/(main)/agent/_layout/AgentIdSync.tsx +++ b/src/routes/(main)/agent/_layout/AgentIdSync.tsx @@ -1,6 +1,6 @@ import { useMount, usePrevious, useUnmount } from 'ahooks'; -import { useEffect } from 'react'; -import { useParams } from 'react-router-dom'; +import { useEffect, useRef } from 'react'; +import { useParams, useSearchParams } from 'react-router-dom'; import { createStoreUpdater } from 'zustand-utils'; import { useAgentStore } from '@/store/agent'; @@ -10,6 +10,9 @@ const AgentIdSync = () => { const useStoreUpdater = createStoreUpdater(useAgentStore); const useChatStoreUpdater = createStoreUpdater(useChatStore); const params = useParams<{ aid?: string }>(); + const [searchParams] = useSearchParams(); + const searchParamsRef = useRef(searchParams); + searchParamsRef.current = searchParams; const prevAgentId = usePrevious(params.aid); useStoreUpdater('activeAgentId', params.aid); @@ -20,7 +23,12 @@ const AgentIdSync = () => { useEffect(() => { // Only reset topic when switching between agents (not on initial mount) if (prevAgentId !== undefined && prevAgentId !== params.aid) { - useChatStore.getState().switchTopic(null, { skipRefreshMessage: true }); + // Preserve topic if the URL already carries one (e.g. tab navigation) + const topicFromUrl = searchParamsRef.current.get('topic'); + + if (!topicFromUrl) { + useChatStore.getState().switchTopic(null, { skipRefreshMessage: true }); + } } // Clear unread completion indicator for the agent being viewed if (params.aid) { diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx index daff467bdb..a92e0e872d 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx @@ -2,15 +2,16 @@ import { ActionIcon, Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui'; import { createStaticStyles, cssVar } from 'antd-style'; import { MessageSquareDashed, Star } from 'lucide-react'; import { AnimatePresence, m as motion } from 'motion/react'; -import { memo, Suspense, useCallback, useMemo } from 'react'; +import { memo, Suspense, 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 { useAgentStore } from '@/store/agent'; import { useChatStore } from '@/store/chat'; import { operationSelectors } from '@/store/chat/selectors'; -import { useGlobalStore } from '@/store/global'; +import { useElectronStore } from '@/store/electron'; import { useTopicNavigation } from '../../hooks/useTopicNavigation'; import ThreadList from '../../TopicListContent/ThreadList'; @@ -54,8 +55,8 @@ interface TopicItemProps { const TopicItem = memo(({ id, title, fav, active, threadId }) => { const { t } = useTranslation('topic'); - const openTopicInNewWindow = useGlobalStore((s) => s.openTopicInNewWindow); const activeAgentId = useAgentStore((s) => s.activeAgentId); + const addTab = useElectronStore((s) => s.addTab); // Construct href for cmd+click support const href = useMemo(() => { @@ -83,17 +84,32 @@ const TopicItem = memo(({ id, title, fav, active, threadId }) => [id], ); + const clickTimerRef = useRef | null>(null); + const handleClick = useCallback(() => { if (editing) return; - navigateToTopic(id); + if (isDesktop) { + clickTimerRef.current = setTimeout(() => { + clickTimerRef.current = null; + navigateToTopic(id); + }, 250); + } else { + navigateToTopic(id); + } }, [editing, id, navigateToTopic]); const handleDoubleClick = useCallback(() => { - if (!id || !activeAgentId) return; - if (isDesktop) { - openTopicInNewWindow(activeAgentId, id); + if (!id || !activeAgentId || !isDesktop) return; + if (clickTimerRef.current) { + clearTimeout(clickTimerRef.current); + clickTimerRef.current = null; } - }, [id, activeAgentId, openTopicInNewWindow]); + const reference = pluginRegistry.parseUrl(`/agent/${activeAgentId}`, `topic=${id}`); + if (reference) { + addTab(reference); + navigateToTopic(id); + } + }, [id, activeAgentId, addTab, navigateToTopic]); const dropdownMenu = useTopicItemDropdownMenu({ id, diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx index 5f6d90f019..061f5b25cf 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx @@ -1,13 +1,16 @@ import { type MenuProps } from '@lobehub/ui'; import { Icon } from '@lobehub/ui'; import { App } from 'antd'; -import { ExternalLink, LucideCopy, PencilLine, Trash, Wand2 } from 'lucide-react'; +import { ExternalLink, LucideCopy, PanelTop, PencilLine, Trash, Wand2 } from 'lucide-react'; import { useCallback } from 'react'; 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 { useAgentStore } from '@/store/agent'; import { useChatStore } from '@/store/chat'; +import { useElectronStore } from '@/store/electron'; import { useGlobalStore } from '@/store/global'; interface TopicItemDropdownMenuProps { @@ -21,9 +24,11 @@ export const useTopicItemDropdownMenu = ({ }: TopicItemDropdownMenuProps): (() => MenuProps['items']) => { const { t } = useTranslation(['topic', 'common']); const { modal } = App.useApp(); + const navigate = useNavigate(); const openTopicInNewWindow = useGlobalStore((s) => s.openTopicInNewWindow); const activeAgentId = useAgentStore((s) => s.activeAgentId); + const addTab = useElectronStore((s) => s.addTab); const [autoRenameTopicTitle, duplicateTopic, removeTopic] = useChatStore((s) => [ s.autoRenameTopicTitle, @@ -53,6 +58,20 @@ export const useTopicItemDropdownMenu = ({ }, ...(isDesktop ? [ + { + icon: , + key: 'openInNewTab', + label: t('actions.openInNewTab'), + onClick: () => { + if (!activeAgentId) return; + const url = `/agent/${activeAgentId}?topic=${id}`; + const reference = pluginRegistry.parseUrl(`/agent/${activeAgentId}`, `topic=${id}`); + if (reference) { + addTab(reference); + navigate(url); + } + }, + }, { icon: , key: 'openInNewWindow', @@ -101,6 +120,8 @@ export const useTopicItemDropdownMenu = ({ duplicateTopic, removeTopic, openTopicInNewWindow, + addTab, + navigate, toggleEditing, t, modal, diff --git a/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx b/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx index a9265b8f4b..2936f3900f 100644 --- a/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx +++ b/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx @@ -1,14 +1,15 @@ import { ActionIcon, Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui'; import { cssVar } from 'antd-style'; import { MessageSquareDashed, Star } from 'lucide-react'; -import { memo, Suspense, useCallback, useMemo } from 'react'; +import { memo, Suspense, 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 { useAgentStore } from '@/store/agent'; import { useAgentGroupStore } from '@/store/agentGroup'; import { useChatStore } from '@/store/chat'; +import { useElectronStore } from '@/store/electron'; import { useGlobalStore } from '@/store/global'; import ThreadList from '../../TopicListContent/ThreadList'; @@ -26,10 +27,9 @@ interface TopicItemProps { const TopicItem = memo(({ id, title, fav, active, threadId }) => { const { t } = useTranslation('topic'); - const openTopicInNewWindow = useGlobalStore((s) => s.openTopicInNewWindow); const toggleMobileTopic = useGlobalStore((s) => s.toggleMobileTopic); - const activeAgentId = useAgentStore((s) => s.activeAgentId); const [activeGroupId, switchTopic] = useAgentGroupStore((s) => [s.activeGroupId, s.switchTopic]); + const addTab = useElectronStore((s) => s.addTab); // Construct href for cmd+click support const href = useMemo(() => { @@ -51,18 +51,35 @@ const TopicItem = memo(({ id, title, fav, active, threadId }) => [id], ); + const clickTimerRef = useRef | null>(null); + const handleClick = useCallback(() => { if (editing) return; - switchTopic(id); - toggleMobileTopic(false); + if (isDesktop) { + clickTimerRef.current = setTimeout(() => { + clickTimerRef.current = null; + switchTopic(id); + toggleMobileTopic(false); + }, 250); + } else { + switchTopic(id); + toggleMobileTopic(false); + } }, [editing, id, switchTopic, toggleMobileTopic]); const handleDoubleClick = useCallback(() => { - if (!id || !activeAgentId) return; - if (isDesktop) { - openTopicInNewWindow(activeAgentId, id); + if (!id || !activeGroupId || !isDesktop) return; + if (clickTimerRef.current) { + clearTimeout(clickTimerRef.current); + clickTimerRef.current = null; } - }, [id, activeAgentId, openTopicInNewWindow]); + const reference = pluginRegistry.parseUrl(`/group/${activeGroupId}`, `topic=${id}`); + if (reference) { + addTab(reference); + switchTopic(id); + toggleMobileTopic(false); + } + }, [id, activeGroupId, addTab, switchTopic, toggleMobileTopic]); const dropdownMenu = useTopicItemDropdownMenu({ id, diff --git a/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx b/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx index 5f6d90f019..7ff54491e7 100644 --- a/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx +++ b/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx @@ -1,13 +1,17 @@ import { type MenuProps } from '@lobehub/ui'; import { Icon } from '@lobehub/ui'; import { App } from 'antd'; -import { ExternalLink, LucideCopy, PencilLine, Trash, Wand2 } from 'lucide-react'; +import { ExternalLink, LucideCopy, PanelTop, PencilLine, Trash, Wand2 } from 'lucide-react'; import { useCallback } from 'react'; 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 { useAgentStore } from '@/store/agent'; +import { useAgentGroupStore } from '@/store/agentGroup'; import { useChatStore } from '@/store/chat'; +import { useElectronStore } from '@/store/electron'; import { useGlobalStore } from '@/store/global'; interface TopicItemDropdownMenuProps { @@ -21,9 +25,12 @@ export const useTopicItemDropdownMenu = ({ }: TopicItemDropdownMenuProps): (() => MenuProps['items']) => { const { t } = useTranslation(['topic', 'common']); const { modal } = App.useApp(); + const navigate = useNavigate(); const openTopicInNewWindow = useGlobalStore((s) => s.openTopicInNewWindow); const activeAgentId = useAgentStore((s) => s.activeAgentId); + const activeGroupId = useAgentGroupStore((s) => s.activeGroupId); + const addTab = useElectronStore((s) => s.addTab); const [autoRenameTopicTitle, duplicateTopic, removeTopic] = useChatStore((s) => [ s.autoRenameTopicTitle, @@ -53,6 +60,20 @@ export const useTopicItemDropdownMenu = ({ }, ...(isDesktop ? [ + { + icon: , + key: 'openInNewTab', + label: t('actions.openInNewTab'), + onClick: () => { + if (!activeGroupId) return; + const url = `/group/${activeGroupId}?topic=${id}`; + const reference = pluginRegistry.parseUrl(`/group/${activeGroupId}`, `topic=${id}`); + if (reference) { + addTab(reference); + navigate(url); + } + }, + }, { icon: , key: 'openInNewWindow', @@ -97,10 +118,13 @@ export const useTopicItemDropdownMenu = ({ }, [ id, activeAgentId, + activeGroupId, autoRenameTopicTitle, duplicateTopic, removeTopic, openTopicInNewWindow, + addTab, + navigate, toggleEditing, t, modal, diff --git a/src/services/chat/index.ts b/src/services/chat/index.ts index 161c70c8ff..e55b77527c 100644 --- a/src/services/chat/index.ts +++ b/src/services/chat/index.ts @@ -159,11 +159,12 @@ class ChatService { const activeAgentId = getChatStoreState().activeAgentId || ''; const baseContext = agentByIdSelectors.getAgentBuilderContextById(activeAgentId)(getAgentStoreState()); + const activeAgentConfig = + agentSelectors.getAgentConfigById(activeAgentId)(getAgentStoreState()); // Build official tools list (builtin tools + Klavis tools) const toolState = getToolStoreState(); - const enabledPlugins = - agentSelectors.getAgentConfigById(activeAgentId)(getAgentStoreState()).plugins || []; + const enabledPlugins = activeAgentConfig?.plugins || []; const officialTools: OfficialToolItem[] = []; diff --git a/src/store/agent/selectors/agentByIdSelectors.test.ts b/src/store/agent/selectors/agentByIdSelectors.test.ts new file mode 100644 index 0000000000..ad3c2e8032 --- /dev/null +++ b/src/store/agent/selectors/agentByIdSelectors.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { type AgentStoreState } from '@/store/agent/initialState'; +import { initialAgentSliceState } from '@/store/agent/slices/agent/initialState'; +import { initialBuiltinAgentSliceState } from '@/store/agent/slices/builtin/initialState'; + +import { agentByIdSelectors } from './agentByIdSelectors'; + +const createState = (overrides: Partial = {}): AgentStoreState => ({ + ...initialAgentSliceState, + ...initialBuiltinAgentSliceState, + ...overrides, +}); + +describe('agentByIdSelectors', () => { + describe('getAgentBuilderContextById', () => { + it('should return builder context from existing agent config', () => { + const state = createState({ + agentMap: { + 'agent-1': { + chatConfig: { historyCount: 6 }, + model: 'gpt-4o', + plugins: ['search'], + provider: 'openai', + systemRole: 'You are a helper', + }, + }, + }); + + const context = agentByIdSelectors.getAgentBuilderContextById('agent-1')(state); + + expect(context.config).toMatchObject({ + chatConfig: { historyCount: 6 }, + model: 'gpt-4o', + plugins: ['search'], + provider: 'openai', + systemRole: 'You are a helper', + }); + }); + + it('should not throw when agent config is missing', () => { + const state = createState({ agentMap: {} }); + + expect(() => + agentByIdSelectors.getAgentBuilderContextById('missing-agent')(state), + ).not.toThrow(); + + const context = agentByIdSelectors.getAgentBuilderContextById('missing-agent')(state); + + expect(context.config).toMatchObject({ + chatConfig: undefined, + model: undefined, + plugins: undefined, + provider: undefined, + systemRole: undefined, + }); + }); + }); +}); diff --git a/src/store/agent/selectors/agentByIdSelectors.ts b/src/store/agent/selectors/agentByIdSelectors.ts index 9dd400a722..40acd6d3ec 100644 --- a/src/store/agent/selectors/agentByIdSelectors.ts +++ b/src/store/agent/selectors/agentByIdSelectors.ts @@ -102,14 +102,14 @@ const getAgentBuilderContextById = return { config: { - chatConfig: config.chatConfig, - model: config.model, - openingMessage: config.openingMessage, - openingQuestions: config.openingQuestions, - params: config.params, - plugins: config.plugins, - provider: config.provider, - systemRole: config.systemRole, + chatConfig: config?.chatConfig, + model: config?.model, + openingMessage: config?.openingMessage, + openingQuestions: config?.openingQuestions, + params: config?.params, + plugins: config?.plugins, + provider: config?.provider, + systemRole: config?.systemRole, }, meta, }; diff --git a/src/store/agent/slices/agent/action.test.ts b/src/store/agent/slices/agent/action.test.ts index 6a1bc4cd37..339d987e61 100644 --- a/src/store/agent/slices/agent/action.test.ts +++ b/src/store/agent/slices/agent/action.test.ts @@ -1,3 +1,4 @@ +import { CHAT_GROUP_SESSION_ID_PREFIX } from '@lobechat/types'; import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -326,9 +327,10 @@ describe('AgentSlice Actions', () => { expect(result.current.data).toBeUndefined(); }); - it('should not fetch when agentId starts with cg_', async () => { + it('should not fetch when agentId is a chat-group session id', async () => { const { result } = renderHook( - () => useAgentStore().useFetchAgentConfig(true, 'cg_group-chat'), + () => + useAgentStore().useFetchAgentConfig(true, `${CHAT_GROUP_SESSION_ID_PREFIX}group-chat`), { wrapper: withSWR }, ); diff --git a/src/store/agent/slices/agent/action.ts b/src/store/agent/slices/agent/action.ts index 72b93c8f82..917401a769 100644 --- a/src/store/agent/slices/agent/action.ts +++ b/src/store/agent/slices/agent/action.ts @@ -1,3 +1,4 @@ +import { isChatGroupSessionId } from '@lobechat/types'; import { getSingletonAnalyticsOptional } from '@lobehub/analytics'; import isEqual from 'fast-deep-equal'; import { produce } from 'immer'; @@ -234,7 +235,7 @@ export class AgentSliceActionImpl { ): SWRResponse => { return useClientDataSWR( // Only fetch when login status is explicitly true (not null/undefined) - isLogin === true && agentId && !agentId.startsWith('cg_') + isLogin === true && agentId && !isChatGroupSessionId(agentId) ? ([FETCH_AGENT_CONFIG_KEY, agentId] as const) : null, async ([, id]: readonly [string, string]) => { diff --git a/src/store/electron/actions/tabPages.ts b/src/store/electron/actions/tabPages.ts new file mode 100644 index 0000000000..52dae87693 --- /dev/null +++ b/src/store/electron/actions/tabPages.ts @@ -0,0 +1,197 @@ +import { + type CachedPageData, + type PageReference, +} from '@/features/Electron/titlebar/RecentlyViewed/types'; +import { getTabPages, saveTabPages } from '@/features/Electron/titlebar/TabBar/storage'; +import { type StoreSetter } from '@/store/types'; + +import { type ElectronStore } from '../store'; + +// ======== Types ======== // + +export interface TabPagesState { + activeTabId: string | null; + tabs: PageReference[]; +} + +// ======== Initial State ======== // + +export const tabPagesInitialState: TabPagesState = { + activeTabId: null, + tabs: [], +}; + +// ======== Action Implementation ======== // + +type Setter = StoreSetter; +export const createTabPagesSlice = (set: Setter, get: () => ElectronStore, _api?: unknown) => + new TabPagesActionImpl(set, get, _api); + +export class TabPagesActionImpl { + readonly #get: () => ElectronStore; + readonly #set: Setter; + + constructor(set: Setter, get: () => ElectronStore, _api?: unknown) { + void _api; + this.#set = set; + this.#get = get; + } + + activateTab = (id: string): void => { + const { tabs } = this.#get(); + if (!tabs.some((t) => t.id === id)) return; + + this.#set({ activeTabId: id }, false, 'activateTab'); + this.#persist(); + }; + + addTab = (reference: PageReference, cached?: CachedPageData, activate = true): void => { + const { tabs } = this.#get(); + const existing = tabs.find((t) => t.id === reference.id); + + if (existing) { + // Tab already exists, just activate + if (activate) { + this.#set({ activeTabId: existing.id }, false, 'activateExistingTab'); + this.#persist(); + } + return; + } + + const newTab: PageReference = { + ...reference, + cached, + lastVisited: Date.now(), + }; + + const newTabs = [...tabs, newTab]; + this.#set( + { activeTabId: activate ? newTab.id : this.#get().activeTabId, tabs: newTabs }, + false, + 'addTab', + ); + this.#persist(); + }; + + getActiveTab = (): PageReference | null => { + const { activeTabId, tabs } = this.#get(); + if (!activeTabId) return null; + return tabs.find((t) => t.id === activeTabId) ?? null; + }; + + loadTabs = (): void => { + const { tabs, activeTabId } = getTabPages(); + this.#set({ activeTabId, tabs }, false, 'loadTabs'); + }; + + removeTab = (id: string): string | null => { + const { tabs, activeTabId } = this.#get(); + const index = tabs.findIndex((t) => t.id === id); + if (index < 0) return null; + + const newTabs = tabs.filter((t) => t.id !== id); + + let newActiveId = activeTabId; + if (activeTabId === id) { + if (newTabs.length === 0) { + newActiveId = null; + } else if (index >= newTabs.length) { + newActiveId = newTabs.at(-1)!.id; + } else { + newActiveId = newTabs[index].id; + } + } + + this.#set({ activeTabId: newActiveId, tabs: newTabs }, false, 'removeTab'); + this.#persist(); + + return newActiveId; + }; + + closeLeftTabs = (id: string): void => { + const { tabs, activeTabId } = this.#get(); + const index = tabs.findIndex((t) => t.id === id); + if (index <= 0) return; + + const newTabs = tabs.slice(index); + const newActiveId = newTabs.some((t) => t.id === activeTabId) ? activeTabId : id; + + this.#set({ activeTabId: newActiveId, tabs: newTabs }, false, 'closeLeftTabs'); + this.#persist(); + }; + + closeOtherTabs = (id: string): void => { + const { tabs } = this.#get(); + const target = tabs.find((t) => t.id === id); + if (!target) return; + + this.#set({ activeTabId: id, tabs: [target] }, false, 'closeOtherTabs'); + this.#persist(); + }; + + closeRightTabs = (id: string): void => { + const { tabs, activeTabId } = this.#get(); + const index = tabs.findIndex((t) => t.id === id); + if (index < 0 || index >= tabs.length - 1) return; + + const newTabs = tabs.slice(0, index + 1); + const newActiveId = newTabs.some((t) => t.id === activeTabId) ? activeTabId : id; + + this.#set({ activeTabId: newActiveId, tabs: newTabs }, false, 'closeRightTabs'); + this.#persist(); + }; + + reorderTabs = (fromIndex: number, toIndex: number): void => { + const { tabs } = this.#get(); + if (fromIndex < 0 || fromIndex >= tabs.length) return; + if (toIndex < 0 || toIndex >= tabs.length) return; + + const newTabs = [...tabs]; + const [moved] = newTabs.splice(fromIndex, 1); + newTabs.splice(toIndex, 0, moved); + + this.#set({ tabs: newTabs }, false, 'reorderTabs'); + this.#persist(); + }; + + updateTab = (id: string, reference: PageReference, cached?: CachedPageData): void => { + const { tabs, activeTabId } = this.#get(); + const index = tabs.findIndex((t) => t.id === id); + if (index < 0) return; + + const newTabs = [...tabs]; + newTabs[index] = { + ...reference, + cached: cached ? { ...newTabs[index].cached, ...cached } : newTabs[index].cached, + lastVisited: Date.now(), + }; + + // Keep activeTabId in sync when the updated tab was the active one + const newActiveTabId = activeTabId === id ? reference.id : activeTabId; + + this.#set({ activeTabId: newActiveTabId, tabs: newTabs }, false, 'updateTab'); + this.#persist(); + }; + + updateTabCache = (id: string, cached: CachedPageData): void => { + const { tabs } = this.#get(); + const index = tabs.findIndex((t) => t.id === id); + if (index < 0) return; + + const newTabs = [...tabs]; + newTabs[index] = { + ...newTabs[index], + cached: { ...newTabs[index].cached, ...cached }, + }; + + this.#set({ tabs: newTabs }, false, 'updateTabCache'); + this.#persist(); + }; + + #persist = (): void => { + const { tabs, activeTabId } = this.#get(); + saveTabPages(tabs, activeTabId); + }; +} + +export type TabPagesAction = Pick; diff --git a/src/store/electron/initialState.ts b/src/store/electron/initialState.ts index cd7bdd9e68..2273d58f23 100644 --- a/src/store/electron/initialState.ts +++ b/src/store/electron/initialState.ts @@ -8,6 +8,8 @@ import { type NavigationHistoryState } from './actions/navigationHistory'; import { navigationHistoryInitialState } from './actions/navigationHistory'; import { type RecentPagesState } from './actions/recentPages'; import { recentPagesInitialState } from './actions/recentPages'; +import { type TabPagesState } from './actions/tabPages'; +import { tabPagesInitialState } from './actions/tabPages'; export type RemoteServerError = 'CONFIG_ERROR' | 'AUTH_ERROR' | 'DISCONNECT_ERROR'; @@ -20,7 +22,7 @@ export const defaultProxySettings: NetworkProxySettings = { proxyType: 'http', }; -export interface ElectronState extends NavigationHistoryState, RecentPagesState { +export interface ElectronState extends NavigationHistoryState, RecentPagesState, TabPagesState { appState: ElectronAppState; dataSyncConfig: DataSyncConfig; desktopHotkeys: Record; @@ -37,6 +39,7 @@ export interface ElectronState extends NavigationHistoryState, RecentPagesState export const initialState: ElectronState = { ...navigationHistoryInitialState, ...recentPagesInitialState, + ...tabPagesInitialState, appState: {}, dataSyncConfig: { storageMode: 'cloud' }, desktopHotkeys: {}, diff --git a/src/store/electron/store.ts b/src/store/electron/store.ts index beddb66a7d..e4698ab9e4 100644 --- a/src/store/electron/store.ts +++ b/src/store/electron/store.ts @@ -14,6 +14,8 @@ import { type ElectronSettingsAction } from './actions/settings'; import { settingsSlice } from './actions/settings'; import { type ElectronRemoteServerAction } from './actions/sync'; import { remoteSyncSlice } from './actions/sync'; +import { type TabPagesAction } from './actions/tabPages'; +import { createTabPagesSlice } from './actions/tabPages'; import { type ElectronState } from './initialState'; import { initialState } from './initialState'; @@ -26,7 +28,8 @@ export interface ElectronStore ElectronAppAction, ElectronSettingsAction, NavigationHistoryAction, - RecentPagesAction { + RecentPagesAction, + TabPagesAction { /* empty */ } @@ -34,7 +37,8 @@ type ElectronStoreAction = ElectronRemoteServerAction & ElectronAppAction & ElectronSettingsAction & NavigationHistoryAction & - RecentPagesAction; + RecentPagesAction & + TabPagesAction; const createStore: StateCreator = ( ...parameters: Parameters> @@ -46,6 +50,7 @@ const createStore: StateCreator = settingsSlice(...parameters), createNavigationHistorySlice(...parameters), createRecentPagesSlice(...parameters), + createTabPagesSlice(...parameters), ]), });