feat(electron): add + button to TabBar for new topic in active context (#13972)

*  feat(electron): add + button to TabBar to open new topic in active context

Introduce a pluggable `createNewTabAction` extension on RecentlyViewed
plugins so each page type can decide whether (and how) to spawn a new
tab from the active tab. Implemented for agent / agent-topic /
group / group-topic — clicking `+` creates a fresh topic under the
current agent/group and opens it as a new tab; other page types hide
the button by default.

*  feat(electron): support new tab from page context

Page plugin now implements `createNewTabAction`, creating a fresh
untitled document via `usePageStore().createPage` and opening it as
a new `page` tab.

* 🐛 fix(electron): refresh page list after creating a new page via TabBar +

`createPage` only hits the service; without refreshing the documents
list, the sidebar / PageExplorer wouldn't show the freshly-created
page until the next full reload.

* 🐛 fix(electron): highlight new page in sidebar when opened via TabBar +

Switch to `createNewPage`, which runs the full optimistic flow —
dispatches the new document into the sidebar list and sets
`selectedPageId` — so the nav item active state stays in sync with
the freshly-opened page tab.

* 🐛 fix(electron): dispatch real page doc into sidebar list for TabBar +

The earlier `createNewPage` approach relied on an optimistic temp
document that SWR revalidation can clobber before the real doc
replaces it, leaving the new page absent from the sidebar. Create
the page via `createPage` first, then synthesize a `LobeDocument`
from the server response and dispatch it into the list alongside
setting `selectedPageId` — the nav item now appears and highlights
in sync with the new tab.
This commit is contained in:
Innei 2026-04-20 01:04:51 +08:00 committed by GitHub
parent 6b6915d147
commit 730169e6b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 309 additions and 8 deletions

View file

@ -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...",

View file

@ -106,6 +106,7 @@
"tab.closeLeftTabs": "关闭左侧标签页",
"tab.closeOtherTabs": "关闭其他标签页",
"tab.closeRightTabs": "关闭右侧标签页",
"tab.newTab": "新建标签页",
"tab.running": "智能体运行中",
"updater.checkingUpdate": "检查新版本",
"updater.checkingUpdateDesc": "正在获取版本信息…",

View file

@ -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}`;
},

View file

@ -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}`;

View file

@ -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}`;
},

View file

@ -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}`;

View file

@ -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<NewTabActionResult | null> => {
const defaultTitle = ctx.t('defaultTitle', { ns: 'topic' });
const topicId = await lambdaClient.topic.createTopic.mutate({
agentId,
messages: [],
title: defaultTitle,
});
await useChatStore.getState().refreshTopic();
const reference: PageReference<'agent-topic'> = {
id: `agent-topic:${agentId}:${topicId}`,
lastVisited: Date.now(),
params: { agentId, topicId },
type: 'agent-topic',
};
const cached: CachedPageData = {
avatar: meta.avatar,
backgroundColor: meta.backgroundColor,
title: defaultTitle,
};
return { cached, reference };
},
};
};
/**
* Build a NewTabAction that creates a fresh topic under a group and
* returns a `group-topic` reference pointing to it.
*/
export const buildGroupNewTopicAction = (
groupId: string,
ctx: PluginContext,
): NewTabAction | null => {
const group = ctx.getSessionGroup(groupId);
if (!group) return null;
return {
onCreate: async (): Promise<NewTabActionResult | null> => {
const defaultTitle = ctx.t('defaultTitle', { ns: 'topic' });
const topicId = await lambdaClient.topic.createTopic.mutate({
groupId,
messages: [],
title: defaultTitle,
});
await useChatStore.getState().refreshTopic();
const reference: PageReference<'group-topic'> = {
id: `group-topic:${groupId}:${topicId}`,
lastVisited: Date.now(),
params: { groupId, topicId },
type: 'group-topic',
};
const cached: CachedPageData = {
title: defaultTitle,
};
return { cached, reference };
},
};
};
/**
* Build a NewTabAction that creates a fresh untitled page document and
* returns a `page` reference pointing to it.
*/
export const buildPageNewTabAction = (ctx: PluginContext): NewTabAction => {
return {
onCreate: async (): Promise<NewTabActionResult | null> => {
const untitled = ctx.t('pageList.untitled', { ns: 'file' });
const pageStore = usePageStore.getState();
// Create the real page via service first — once the row exists on
// the server, any SWR revalidation of the page list will include
// it and won't clobber the optimistic add we do below.
const newPage = await pageStore.createPage({ content: '', title: untitled });
// Synthesize a `LobeDocument` for the sidebar list.
const now = new Date();
const document: LobeDocument = {
content: newPage.content || '',
createdAt: newPage.createdAt ? new Date(newPage.createdAt) : now,
editorData:
typeof newPage.editorData === 'string'
? (() => {
try {
return JSON.parse(newPage.editorData);
} catch {
return null;
}
})()
: newPage.editorData || null,
fileType: newPage.fileType || EDITOR_PAGE_FILE_TYPE,
filename: newPage.title || untitled,
id: newPage.id,
metadata: newPage.metadata || {},
source: 'document',
sourceType: DocumentSourceType.EDITOR,
title: newPage.title || untitled,
totalCharCount: (newPage.content || '').length,
totalLineCount: 0,
updatedAt: newPage.updatedAt ? new Date(newPage.updatedAt) : now,
};
// Dispatch into the sidebar list and mark selected so the nav item
// highlights in sync with the new tab.
pageStore.internal_dispatchDocuments({ document, type: 'addDocument' });
usePageStore.setState({ selectedPageId: newPage.id }, false, 'TabBar/newPage');
const reference: PageReference<'page'> = {
id: `page:${newPage.id}`,
lastVisited: Date.now(),
params: { pageId: newPage.id },
type: 'page',
};
const cached: CachedPageData = {
title: document.title || untitled,
};
return { cached, reference };
},
};
};

View file

@ -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}`;
},

View file

@ -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
*/

View file

@ -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<NewTabActionResult | null>;
}
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<T extends PageType = PageType> {
*/
checkExists: (reference: PageReference<T>, ctx: PluginContext) => boolean;
/**
* Build a "new tab" action for the TabBar "+" button. Return null to
* hide the button when this plugin's reference is active.
*/
createNewTabAction?: (reference: PageReference<T>, ctx: PluginContext) => NewTabAction | null;
/**
* Generate unique ID from reference params
* e.g., "agent:abc123" or "agent-topic:abc123:topic456"

View file

@ -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<HTMLDivElement>(null);
const { tabs, activeTabId } = useResolvedTabs();
const pluginCtx = usePluginContext();
const activateTab = useElectronStore((s) => s.activateTab);
const addTab = useElectronStore((s) => s.addTab);
const removeTab = useElectronStore((s) => s.removeTab);
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 (
<ScrollArea
@ -140,6 +178,15 @@ const TabBar = () => {
onCloseRight={handleCloseRight}
/>
))}
{newTabAction && (
<ActionIcon
className={cx(electronStylish.nodrag, styles.newTabButton)}
icon={Plus}
size="small"
title={t('tab.newTab')}
onClick={handleNewTab}
/>
)}
</ScrollArea>
);
};

View file

@ -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};
}
`,
}));

View file

@ -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',