diff --git a/locales/en-US/electron.json b/locales/en-US/electron.json index a984bf8616..37c93591b5 100644 --- a/locales/en-US/electron.json +++ b/locales/en-US/electron.json @@ -106,6 +106,7 @@ "tab.closeLeftTabs": "Close Tabs to the Left", "tab.closeOtherTabs": "Close Other Tabs", "tab.closeRightTabs": "Close Tabs to the Right", + "tab.newTab": "New Tab", "tab.running": "Agent is running", "updater.checkingUpdate": "Checking for updates", "updater.checkingUpdateDesc": "Retrieving version information...", diff --git a/locales/zh-CN/electron.json b/locales/zh-CN/electron.json index f214c86a94..7067047b9d 100644 --- a/locales/zh-CN/electron.json +++ b/locales/zh-CN/electron.json @@ -106,6 +106,7 @@ "tab.closeLeftTabs": "关闭左侧标签页", "tab.closeOtherTabs": "关闭其他标签页", "tab.closeRightTabs": "关闭右侧标签页", + "tab.newTab": "新建标签页", "tab.running": "智能体运行中", "updater.checkingUpdate": "检查新版本", "updater.checkingUpdateDesc": "正在获取版本信息…", diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/agentPlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/agentPlugin.ts index e40cd7bf89..3341aa752f 100644 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/agentPlugin.ts +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/agentPlugin.ts @@ -3,7 +3,8 @@ 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 { buildAgentNewTopicAction } from './newTabHelpers'; +import { type NewTabAction, type PluginContext, type RecentlyViewedPlugin } from './types'; import { createPageReference } from './types'; const AGENT_PATH_REGEX = /^\/agent\/([^/?]+)$/; @@ -13,6 +14,11 @@ export const agentPlugin: RecentlyViewedPlugin<'agent'> = { 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}`; }, diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/agentTopicPlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/agentTopicPlugin.ts index 60e219aaa8..c154738914 100644 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/agentTopicPlugin.ts +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/agentTopicPlugin.ts @@ -3,7 +3,8 @@ 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 { buildAgentNewTopicAction } from './newTabHelpers'; +import { type NewTabAction, type PluginContext, type RecentlyViewedPlugin } from './types'; import { createPageReference } from './types'; const AGENT_PATH_REGEX = /^\/agent\/([^/?]+)$/; @@ -18,6 +19,13 @@ export const agentTopicPlugin: RecentlyViewedPlugin<'agent-topic'> = { 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}`; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/groupPlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/groupPlugin.ts index 88d66a0618..ee5c575c34 100644 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/groupPlugin.ts +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/groupPlugin.ts @@ -1,7 +1,8 @@ import { Users } from 'lucide-react'; import { type GroupParams, type PageReference, type ResolvedPageData } from '../types'; -import { type PluginContext, type RecentlyViewedPlugin } from './types'; +import { buildGroupNewTopicAction } from './newTabHelpers'; +import { type NewTabAction, type PluginContext, type RecentlyViewedPlugin } from './types'; import { createPageReference } from './types'; const GROUP_PATH_REGEX = /^\/group\/([^/?]+)$/; @@ -11,6 +12,11 @@ export const groupPlugin: RecentlyViewedPlugin<'group'> = { 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}`; }, diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/groupTopicPlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/groupTopicPlugin.ts index b54425e0da..be81a996d7 100644 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/groupTopicPlugin.ts +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/groupTopicPlugin.ts @@ -1,7 +1,8 @@ import { Users } from 'lucide-react'; import { type GroupTopicParams, type PageReference, type ResolvedPageData } from '../types'; -import { type PluginContext, type RecentlyViewedPlugin } from './types'; +import { buildGroupNewTopicAction } from './newTabHelpers'; +import { type NewTabAction, type PluginContext, type RecentlyViewedPlugin } from './types'; import { createPageReference } from './types'; const GROUP_PATH_REGEX = /^\/group\/([^/?]+)$/; @@ -16,6 +17,13 @@ export const groupTopicPlugin: RecentlyViewedPlugin<'group-topic'> = { 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}`; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/newTabHelpers.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/newTabHelpers.ts new file mode 100644 index 0000000000..ee3eabe6e5 --- /dev/null +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/newTabHelpers.ts @@ -0,0 +1,152 @@ +import { lambdaClient } from '@/libs/trpc/client'; +import { useChatStore } from '@/store/chat'; +import { usePageStore } from '@/store/page'; +import { DocumentSourceType, type LobeDocument } from '@/types/document'; + +import { type CachedPageData, type PageReference } from '../types'; +import { type NewTabAction, type NewTabActionResult, type PluginContext } from './types'; + +const EDITOR_PAGE_FILE_TYPE = 'custom/document'; + +/** + * Build a NewTabAction that creates a fresh topic under an agent and + * returns an `agent-topic` reference pointing to it. The new reference + * id embeds the topicId, which is globally unique, so it never collides + * with the existing tab it was opened from. + */ +export const buildAgentNewTopicAction = ( + agentId: string, + ctx: PluginContext, +): NewTabAction | null => { + const meta = ctx.getAgentMeta(agentId); + if (!meta || Object.keys(meta).length === 0) return null; + + return { + onCreate: async (): Promise => { + const defaultTitle = ctx.t('defaultTitle', { ns: 'topic' }); + const topicId = await lambdaClient.topic.createTopic.mutate({ + agentId, + messages: [], + title: defaultTitle, + }); + + await useChatStore.getState().refreshTopic(); + + const reference: PageReference<'agent-topic'> = { + id: `agent-topic:${agentId}:${topicId}`, + lastVisited: Date.now(), + params: { agentId, topicId }, + type: 'agent-topic', + }; + + const cached: CachedPageData = { + avatar: meta.avatar, + backgroundColor: meta.backgroundColor, + title: defaultTitle, + }; + + return { cached, reference }; + }, + }; +}; + +/** + * Build a NewTabAction that creates a fresh topic under a group and + * returns a `group-topic` reference pointing to it. + */ +export const buildGroupNewTopicAction = ( + groupId: string, + ctx: PluginContext, +): NewTabAction | null => { + const group = ctx.getSessionGroup(groupId); + if (!group) return null; + + return { + onCreate: async (): Promise => { + const defaultTitle = ctx.t('defaultTitle', { ns: 'topic' }); + const topicId = await lambdaClient.topic.createTopic.mutate({ + groupId, + messages: [], + title: defaultTitle, + }); + + await useChatStore.getState().refreshTopic(); + + const reference: PageReference<'group-topic'> = { + id: `group-topic:${groupId}:${topicId}`, + lastVisited: Date.now(), + params: { groupId, topicId }, + type: 'group-topic', + }; + + const cached: CachedPageData = { + title: defaultTitle, + }; + + return { cached, reference }; + }, + }; +}; + +/** + * Build a NewTabAction that creates a fresh untitled page document and + * returns a `page` reference pointing to it. + */ +export const buildPageNewTabAction = (ctx: PluginContext): NewTabAction => { + return { + onCreate: async (): Promise => { + const untitled = ctx.t('pageList.untitled', { ns: 'file' }); + const pageStore = usePageStore.getState(); + + // Create the real page via service first — once the row exists on + // the server, any SWR revalidation of the page list will include + // it and won't clobber the optimistic add we do below. + const newPage = await pageStore.createPage({ content: '', title: untitled }); + + // Synthesize a `LobeDocument` for the sidebar list. + const now = new Date(); + const document: LobeDocument = { + content: newPage.content || '', + createdAt: newPage.createdAt ? new Date(newPage.createdAt) : now, + editorData: + typeof newPage.editorData === 'string' + ? (() => { + try { + return JSON.parse(newPage.editorData); + } catch { + return null; + } + })() + : newPage.editorData || null, + fileType: newPage.fileType || EDITOR_PAGE_FILE_TYPE, + filename: newPage.title || untitled, + id: newPage.id, + metadata: newPage.metadata || {}, + source: 'document', + sourceType: DocumentSourceType.EDITOR, + title: newPage.title || untitled, + totalCharCount: (newPage.content || '').length, + totalLineCount: 0, + updatedAt: newPage.updatedAt ? new Date(newPage.updatedAt) : now, + }; + + // Dispatch into the sidebar list and mark selected so the nav item + // highlights in sync with the new tab. + pageStore.internal_dispatchDocuments({ document, type: 'addDocument' }); + usePageStore.setState({ selectedPageId: newPage.id }, false, 'TabBar/newPage'); + + const reference: PageReference<'page'> = { + id: `page:${newPage.id}`, + lastVisited: Date.now(), + params: { pageId: newPage.id }, + type: 'page', + }; + + const cached: CachedPageData = { + title: document.title || untitled, + }; + + return { cached, reference }; + }, + }; +}; diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/pagePlugin.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/pagePlugin.ts index adbdcdc62a..0813999b21 100644 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/pagePlugin.ts +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/pagePlugin.ts @@ -3,7 +3,8 @@ import { FileText } from 'lucide-react'; import { getRouteById } from '@/config/routes'; import { type PageParams, type PageReference, type ResolvedPageData } from '../types'; -import { type PluginContext, type RecentlyViewedPlugin } from './types'; +import { buildPageNewTabAction } from './newTabHelpers'; +import { type NewTabAction, type PluginContext, type RecentlyViewedPlugin } from './types'; import { createPageReference } from './types'; const PAGE_PATH_REGEX = /^\/page\/([^/?]+)$/; @@ -15,6 +16,11 @@ export const pagePlugin: RecentlyViewedPlugin<'page'> = { 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}`; }, diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/registry.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/registry.ts index 7720ff8603..f50cae05d6 100644 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/registry.ts +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/registry.ts @@ -1,6 +1,7 @@ import { type PageReference, type PageType, type ResolvedPageData } from '../types'; import { type BaseRecentlyViewedPlugin, + type NewTabAction, type PluginContext, type RecentlyViewedPlugin, } from './types'; @@ -225,6 +226,16 @@ class PluginRegistry { 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 */ diff --git a/src/features/Electron/titlebar/RecentlyViewed/plugins/types.ts b/src/features/Electron/titlebar/RecentlyViewed/plugins/types.ts index df7091e4d0..bc134033c7 100644 --- a/src/features/Electron/titlebar/RecentlyViewed/plugins/types.ts +++ b/src/features/Electron/titlebar/RecentlyViewed/plugins/types.ts @@ -6,12 +6,33 @@ import { type SessionGroupItem } from '@/types/session'; import { type ChatTopic } from '@/types/topic'; import { + type CachedPageData, type PageParamsMap, type PageReference, type PageType, type ResolvedPageData, } from '../types'; +// ======== New Tab Action ======== // + +/** + * Descriptor returned by a plugin to enable the TabBar "+" button + * for a given active reference. + */ +export interface NewTabAction { + /** + * Produce a new PageReference (plus optional cached display data) for + * a fresh tab in the same context as the active tab. Return null to + * cancel the creation (e.g. missing prerequisites). + */ + onCreate: () => Promise; +} + +export interface NewTabActionResult { + cached?: CachedPageData; + reference: PageReference; +} + // ======== Plugin Context ======== // /** @@ -52,6 +73,12 @@ export interface BaseRecentlyViewedPlugin { */ 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 */ @@ -110,6 +137,12 @@ export interface RecentlyViewedPlugin { */ checkExists: (reference: PageReference, ctx: PluginContext) => boolean; + /** + * Build a "new tab" action for the TabBar "+" button. Return null to + * hide the button when this plugin's reference is active. + */ + createNewTabAction?: (reference: PageReference, ctx: PluginContext) => NewTabAction | null; + /** * Generate unique ID from reference params * e.g., "agent:abc123" or "agent-topic:abc123:topic456" diff --git a/src/features/Electron/titlebar/TabBar/index.tsx b/src/features/Electron/titlebar/TabBar/index.tsx index 60f81c49b7..ee4b03dad7 100644 --- a/src/features/Electron/titlebar/TabBar/index.tsx +++ b/src/features/Electron/titlebar/TabBar/index.tsx @@ -1,11 +1,16 @@ 'use client'; -import { ScrollArea } from '@lobehub/ui'; -import { startTransition, useCallback, useEffect, useRef } from 'react'; +import { ActionIcon, ScrollArea } from '@lobehub/ui'; +import { cx } from 'antd-style'; +import { Plus } from 'lucide-react'; +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 { useElectronStore } from '@/store/electron'; +import { electronStylish } from '@/styles/electron'; import { useResolvedTabs } from './hooks/useResolvedTabs'; import { useStyles } from './styles'; @@ -17,9 +22,12 @@ const TAB_GAP = 2; const TabBar = () => { const styles = useStyles; const navigate = useNavigate(); + const { t } = useTranslation('electron'); const viewportRef = useRef(null); const { tabs, activeTabId } = useResolvedTabs(); + const pluginCtx = usePluginContext(); const activateTab = useElectronStore((s) => s.activateTab); + const addTab = useElectronStore((s) => s.addTab); const removeTab = useElectronStore((s) => s.removeTab); const closeOtherTabs = useElectronStore((s) => s.closeOtherTabs); const closeLeftTabs = useElectronStore((s) => s.closeLeftTabs); @@ -116,7 +124,37 @@ const TabBar = () => { } }, [activeTabId, tabs]); - if (tabs.length < 2) return null; + const activeReference = useMemo(() => { + if (!activeTabId) return null; + return tabs.find((t) => t.reference.id === activeTabId)?.reference ?? null; + }, [activeTabId, tabs]); + + const newTabAction = useMemo(() => { + if (!activeReference) return null; + return pluginRegistry.getNewTabAction(activeReference, pluginCtx); + }, [activeReference, pluginCtx]); + + const handleNewTab = useCallback(async () => { + if (!newTabAction) return; + let result; + try { + result = await newTabAction.onCreate(); + } catch (error) { + console.error('[TabBar] failed to create new tab:', error); + return; + } + 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]); + + if (tabs.length === 0) return null; return ( { onCloseRight={handleCloseRight} /> ))} + {newTabAction && ( + + )} ); }; diff --git a/src/features/Electron/titlebar/TabBar/styles.ts b/src/features/Electron/titlebar/TabBar/styles.ts index 51ee2ec063..045b294653 100644 --- a/src/features/Electron/titlebar/TabBar/styles.ts +++ b/src/features/Electron/titlebar/TabBar/styles.ts @@ -77,4 +77,25 @@ export const useStyles = createStaticStyles(({ css, cssVar }) => ({ text-overflow: ellipsis; white-space: nowrap; `, + newTabButton: css` + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + + width: 26px; + height: 22px; + border-radius: ${cssVar.borderRadiusSM}; + + color: ${cssVar.colorTextSecondary}; + + transition: + background-color 0.15s ${cssVar.motionEaseInOut}, + color 0.15s ${cssVar.motionEaseInOut}; + + &:hover { + color: ${cssVar.colorText}; + background-color: ${cssVar.colorFillTertiary}; + } + `, })); diff --git a/src/locales/default/electron.ts b/src/locales/default/electron.ts index 0f98616fcc..cf5847272a 100644 --- a/src/locales/default/electron.ts +++ b/src/locales/default/electron.ts @@ -32,6 +32,7 @@ export default { 'tab.closeLeftTabs': 'Close Tabs to the Left', 'tab.closeOtherTabs': 'Close Other Tabs', 'tab.closeRightTabs': 'Close Tabs to the Right', + 'tab.newTab': 'New Tab', 'tab.running': 'Agent is running', 'proxy.auth': 'Authentication Required', 'proxy.authDesc': 'If the proxy server requires a username and password',