feat(electron): add Cmd+W/Cmd+T tab shortcuts with misc desktop polish (#13983)

* 💄 style(topic): darken project group folder label in sidebar

Previous `type='secondary'` on the group title was too faint against the
sidebar background; promote the text to default color for better
legibility and keep the folder icon at tertiary so it stays subtle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(topic): use colorTextSecondary for project group title

Text's `type='secondary'` resolves to a lighter token than
`colorTextSecondary`; apply `colorTextSecondary` directly so the title
lands at the intended shade (darker than before, lighter than default).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(electron): show blue unread dot on tab when agent has unread badge

Mirror the sidebar agent unread badge on the corresponding browser-like tab as a subtle blue dot, so unread completions are visible even when the sidebar is out of view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(electron): forward proxy env vars to spawned agent CLI

The main-process undici dispatcher set by ProxyDispatcherManager only
covers in-process requests — child processes like claude-code CLI never
saw the user's proxy config. Extract a shared `buildProxyEnv` so any CLI
spawn can merge HTTP(S)_PROXY / ALL_PROXY / NO_PROXY into its env.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(electron): close active tab on Cmd+W when multiple tabs are open

Cmd/Ctrl+W now closes the focused tab first and only closes the window when
a single tab (or none) remains.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(electron): add Cmd+T shortcut to open a new tab

Reuses the active tab's plugin context to create a same-type tab, mirroring
the TabBar + button behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(electron): use container color for active tab background

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(electron): update Close menu item expectations for smart Cmd+W

Tests now assert the CmdOrCtrl+W accelerator and click handler instead of
the legacy role: 'close'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(electron): drop const/store import from HeterogeneousAgentCtr

The controller previously pulled defaultProxySettings from @/const/store,
which chain-loads @/modules/updater/configs and electron-is — that breaks
any unit test that mocks `electron` without a full app shim. Make
buildProxyEnv accept undefined and read the store value directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin Xu 2026-04-20 12:38:54 +08:00 committed by GitHub
parent e7236c0169
commit ed64e2b8af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 291 additions and 17 deletions

View file

@ -48,6 +48,7 @@
"file.newAgent": "新建助手",
"file.newAgentGroup": "新建助手组",
"file.newPage": "新建页面",
"file.newTab": "新建标签页",
"file.newTopic": "新建话题",
"file.preferences": "设置…",
"file.quit": "退出",

View file

@ -7,6 +7,7 @@ import type { Readable, Writable } from 'node:stream';
import { app as electronApp, BrowserWindow } from 'electron';
import { buildProxyEnv } from '@/modules/networkProxy/envBuilder';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
@ -306,10 +307,14 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
// the claude binary can leave bash/grep/etc. tool children running and
// the CLI hung waiting on them. Windows has different semantics — use
// taskkill /T /F there; no detached flag needed.
// Forward the user's proxy settings to the CLI. The main-process undici
// dispatcher doesn't reach child processes — they need env vars.
const proxyEnv = buildProxyEnv(this.app.storeManager.get('networkProxy'));
const proc = spawn(session.command, cliArgs, {
cwd,
detached: process.platform !== 'win32',
env: { ...process.env, ...session.env },
env: { ...process.env, ...proxyEnv, ...session.env },
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
});

View file

@ -82,7 +82,10 @@ describe('HeterogeneousAgentCtr', () => {
describe('resolveImage', () => {
it('stores traversal-looking ids inside the cache root via a stable hash key', async () => {
const ctr = new HeterogeneousAgentCtr({ appStoragePath } as any);
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
} as any);
const cacheDir = path.join(appStoragePath, 'heteroAgent/files');
const escapedTargetName = `${path.basename(appStoragePath)}-outside-storage`;
const escapePath = path.join(cacheDir, `../../../${escapedTargetName}`);
@ -112,7 +115,10 @@ describe('HeterogeneousAgentCtr', () => {
});
it('does not trust pre-seeded out-of-root traversal cache files as cache hits', async () => {
const ctr = new HeterogeneousAgentCtr({ appStoragePath } as any);
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
} as any);
const cacheDir = path.join(appStoragePath, 'heteroAgent/files');
const traversalId = '../../preexisting-secret';
const outOfRootDataPath = path.join(cacheDir, traversalId);
@ -144,7 +150,10 @@ describe('HeterogeneousAgentCtr', () => {
const { proc, writes } = createFakeProc();
nextFakeProc = proc;
const ctr = new HeterogeneousAgentCtr({ appStoragePath } as any);
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
} as any);
const { sessionId } = await ctr.startSession({
agentType: 'claude-code',
command: 'claude',

View file

@ -48,6 +48,7 @@ const menu = {
'file.newAgent': 'New Agent',
'file.newAgentGroup': 'New Agent Group',
'file.newPage': 'New Page',
'file.newTab': 'New Tab',
'file.newTopic': 'New Topic',
'file.preferences': 'Preferences',
'file.quit': 'Quit',

View file

@ -309,14 +309,16 @@ describe('LinuxMenu', () => {
expect(copyItem.role).toBe('copy');
});
it('should use role for close (accelerator handled by Electron)', () => {
it('should bind CmdOrCtrl+W to a smart close handler (tab first, then window)', () => {
linuxMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
const fileMenu = template.find((item: any) => item.label === 'File');
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
expect(closeItem.role).toBe('close');
expect(closeItem.accelerator).toBe('CmdOrCtrl+W');
expect(typeof closeItem.click).toBe('function');
expect(closeItem.role).toBeUndefined();
});
it('should use role for minimize (accelerator handled by Electron)', () => {

View file

@ -1,5 +1,5 @@
import type { MenuItemConstructorOptions } from 'electron';
import { app, clipboard, dialog, Menu, shell } from 'electron';
import { app, BrowserWindow, clipboard, dialog, Menu, shell } from 'electron';
import { isDev } from '@/const/env';
@ -64,6 +64,15 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
},
label: t('file.newTopic'),
},
{
accelerator: 'Ctrl+T',
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.show();
mainWindow.broadcast('createNewTab');
},
label: t('file.newTab'),
},
{ type: 'separator' },
{
accelerator: 'Alt+Ctrl+A',
@ -104,7 +113,20 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
label: t('common.checkUpdates'),
},
{ type: 'separator' },
{ label: t('window.close'), role: 'close' },
{
accelerator: 'CmdOrCtrl+W',
click: () => {
const focused = BrowserWindow.getFocusedWindow();
if (!focused) return;
const mainWindow = this.app.browserManager.getMainWindow();
if (focused === mainWindow.browserWindow) {
mainWindow.broadcast('closeCurrentTabOrWindow');
} else {
focused.close();
}
},
label: t('window.close'),
},
{ label: t('window.minimize'), role: 'minimize' },
{ type: 'separator' },
{ label: t('file.quit'), role: 'quit' },

View file

@ -1,7 +1,7 @@
import * as path from 'node:path';
import type { MenuItemConstructorOptions } from 'electron';
import { app, clipboard, Menu, shell } from 'electron';
import { app, BrowserWindow, clipboard, Menu, shell } from 'electron';
import { isDev } from '@/const/env';
import NotificationCtr from '@/controllers/NotificationCtr';
@ -116,6 +116,15 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
},
label: t('file.newTopic'),
},
{
accelerator: 'Command+T',
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.show();
mainWindow.broadcast('createNewTab');
},
label: t('file.newTab'),
},
{ type: 'separator' },
{
accelerator: 'Alt+Command+A',
@ -145,7 +154,20 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
label: t('file.newPage'),
},
{ type: 'separator' },
{ label: t('window.close'), role: 'close' },
{
accelerator: 'CmdOrCtrl+W',
click: () => {
const focused = BrowserWindow.getFocusedWindow();
if (!focused) return;
const mainWindow = this.app.browserManager.getMainWindow();
if (focused === mainWindow.browserWindow) {
mainWindow.broadcast('closeCurrentTabOrWindow');
} else {
focused.close();
}
},
label: t('window.close'),
},
],
},
{

View file

@ -398,10 +398,12 @@ describe('WindowsMenu', () => {
const windowMenu = template.find((item: any) => item.label === 'Window');
const minimizeItem = windowMenu.submenu.find((item: any) => item.role === 'minimize');
const closeItem = windowMenu.submenu.find((item: any) => item.role === 'close');
const closeItem = windowMenu.submenu.find((item: any) => item.label === 'Close');
expect(minimizeItem).toBeDefined();
expect(closeItem).toBeDefined();
expect(closeItem.accelerator).toBe('CmdOrCtrl+W');
expect(typeof closeItem.click).toBe('function');
});
it('should have zoom controls in view menu', () => {

View file

@ -1,5 +1,5 @@
import type { MenuItemConstructorOptions } from 'electron';
import { app, clipboard, Menu, shell } from 'electron';
import { app, BrowserWindow, clipboard, Menu, shell } from 'electron';
import { isDev } from '@/const/env';
@ -63,6 +63,15 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
},
label: t('file.newTopic'),
},
{
accelerator: 'Ctrl+T',
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.show();
mainWindow.broadcast('createNewTab');
},
label: t('file.newTab'),
},
{ type: 'separator' },
{
accelerator: 'Alt+Ctrl+A',
@ -167,7 +176,20 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
label: t('window.title'),
submenu: [
{ label: t('window.minimize'), role: 'minimize' },
{ label: t('window.close'), role: 'close' },
{
accelerator: 'CmdOrCtrl+W',
click: () => {
const focused = BrowserWindow.getFocusedWindow();
if (!focused) return;
const mainWindow = this.app.browserManager.getMainWindow();
if (focused === mainWindow.browserWindow) {
mainWindow.broadcast('closeCurrentTabOrWindow');
} else {
focused.close();
}
},
label: t('window.close'),
},
],
},
{

View file

@ -0,0 +1,81 @@
import type { NetworkProxySettings } from '@lobechat/electron-client-ipc';
import { describe, expect, it } from 'vitest';
import { buildProxyEnv } from '../envBuilder';
describe('buildProxyEnv', () => {
const baseConfig: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: 'proxy.example.com',
proxyPort: '8080',
proxyRequireAuth: false,
proxyBypass: 'localhost,127.0.0.1,::1',
};
it('should return empty object when proxy is disabled', () => {
const env = buildProxyEnv({ ...baseConfig, enableProxy: false });
expect(env).toEqual({});
});
it('should return empty object when proxy server is empty', () => {
const env = buildProxyEnv({ ...baseConfig, proxyServer: '' });
expect(env).toEqual({});
});
it('should return empty object when proxy port is empty', () => {
const env = buildProxyEnv({ ...baseConfig, proxyPort: '' });
expect(env).toEqual({});
});
it('should set HTTP(S)_PROXY for http proxy', () => {
const env = buildProxyEnv({ ...baseConfig, proxyType: 'http' });
expect(env.HTTP_PROXY).toBe('http://proxy.example.com:8080');
expect(env.HTTPS_PROXY).toBe('http://proxy.example.com:8080');
expect(env.ALL_PROXY).toBeUndefined();
});
it('should set HTTP(S)_PROXY for https proxy', () => {
const env = buildProxyEnv({ ...baseConfig, proxyType: 'https' });
expect(env.HTTP_PROXY).toBe('https://proxy.example.com:8080');
expect(env.HTTPS_PROXY).toBe('https://proxy.example.com:8080');
expect(env.ALL_PROXY).toBeUndefined();
});
it('should set ALL_PROXY for socks5 proxy and skip HTTP(S)_PROXY', () => {
const env = buildProxyEnv({ ...baseConfig, proxyType: 'socks5' });
expect(env.ALL_PROXY).toBe('socks5://proxy.example.com:8080');
expect(env.HTTP_PROXY).toBeUndefined();
expect(env.HTTPS_PROXY).toBeUndefined();
});
it('should include NO_PROXY from proxyBypass', () => {
const env = buildProxyEnv(baseConfig);
expect(env.NO_PROXY).toBe('localhost,127.0.0.1,::1');
});
it('should omit NO_PROXY when proxyBypass is empty', () => {
const env = buildProxyEnv({ ...baseConfig, proxyBypass: '' });
expect(env.NO_PROXY).toBeUndefined();
});
it('should include auth in proxy URL', () => {
const env = buildProxyEnv({
...baseConfig,
proxyRequireAuth: true,
proxyUsername: 'user',
proxyPassword: 'pass',
});
expect(env.HTTP_PROXY).toBe('http://user:pass@proxy.example.com:8080');
expect(env.HTTPS_PROXY).toBe('http://user:pass@proxy.example.com:8080');
});
});

View file

@ -0,0 +1,36 @@
import type { NetworkProxySettings } from '@lobechat/electron-client-ipc';
import { ProxyUrlBuilder } from './urlBuilder';
/**
* Build proxy env vars (HTTPS_PROXY / HTTP_PROXY / ALL_PROXY / NO_PROXY) to
* forward the user's proxy config to spawned child processes (e.g. CLI tools
* like claude-code, codex, MCP stdio servers). The in-process undici
* dispatcher set by ProxyDispatcherManager only covers the main process
* children need env vars to pick it up.
*
* Returns `{}` when proxy is disabled, so callers can unconditionally spread
* the result into the spawn env.
*/
export const buildProxyEnv = (config?: NetworkProxySettings): Record<string, string> => {
if (!config?.enableProxy || !config.proxyServer || !config.proxyPort) {
return {};
}
const url = ProxyUrlBuilder.build(config);
const env: Record<string, string> = {};
// SOCKS5 is not universally supported via HTTP(S)_PROXY — stick to ALL_PROXY.
if (config.proxyType === 'socks5') {
env.ALL_PROXY = url;
} else {
env.HTTP_PROXY = url;
env.HTTPS_PROXY = url;
}
if (config.proxyBypass) {
env.NO_PROXY = config.proxyBypass;
}
return env;
};

View file

@ -1,4 +1,5 @@
export { ProxyDispatcherManager } from './dispatcher';
export { buildProxyEnv } from './envBuilder';
export type { ProxyTestResult } from './tester';
export { ProxyConnectionTester } from './tester';
export { ProxyUrlBuilder } from './urlBuilder';

View file

@ -1,4 +1,10 @@
export interface NavigationBroadcastEvents {
/**
* Ask renderer to close the active tab, or fall back to closing the window
* when only one (or zero) tab is left. Triggered by Cmd/Ctrl+W on the main window.
*/
closeCurrentTabOrWindow: () => void;
/**
* Ask renderer to create a new agent.
* Triggered from the main process File menu.
@ -17,6 +23,12 @@ export interface NavigationBroadcastEvents {
*/
createNewPage: () => void;
/**
* Ask renderer to open a new tab based on the currently active tab's context.
* Triggered by Cmd/Ctrl+T on the main window.
*/
createNewTab: () => void;
/**
* Ask renderer to create a new topic (start a new conversation).
* Triggered from the main process File menu.

View file

@ -17,6 +17,7 @@ import { type ResolvedPageData } from '@/features/Electron/titlebar/RecentlyView
import { electronStylish } from '@/styles/electron';
import { useTabRunning } from './hooks/useTabRunning';
import { useTabUnread } from './hooks/useTabUnread';
import { useStyles } from './styles';
interface TabItemProps {
@ -47,6 +48,8 @@ const TabItem = memo<TabItemProps>(
const { t } = useTranslation('electron');
const id = item.reference.id;
const isRunning = useTabRunning(item.reference);
const isUnread = useTabUnread(item.reference);
const showUnreadDot = !isRunning && isUnread;
const handleClick = useCallback(() => {
if (!isActive) {
@ -110,12 +113,16 @@ const TabItem = memo<TabItemProps>(
size={16}
/>
{isRunning && <span aria-label={t('tab.running')} className={styles.runningDot} />}
{showUnreadDot && <span aria-label={t('tab.unread')} className={styles.unreadDot} />}
</span>
) : (
item.icon && (
<span className={styles.avatarWrapper}>
<Icon className={styles.tabIcon} icon={item.icon} size="small" />
{isRunning && <span aria-label={t('tab.running')} className={styles.runningDot} />}
{showUnreadDot && (
<span aria-label={t('tab.unread')} className={styles.unreadDot} />
)}
</span>
)
)}

View file

@ -0,0 +1,24 @@
import {
type AgentParams,
type AgentTopicParams,
type PageReference,
} from '@/features/Electron/titlebar/RecentlyViewed/types';
import { useChatStore } from '@/store/chat';
import { operationSelectors } from '@/store/chat/selectors';
/**
* Whether this tab has an unread completed generation.
* Mirrors the sidebar agent badge, shown as a subtle dot on the tab.
*/
export const useTabUnread = (reference: PageReference): boolean =>
useChatStore((s) => {
if (reference.type === 'agent') {
const { agentId } = reference.params as AgentParams;
return operationSelectors.isAgentUnreadCompleted(agentId)(s);
}
if (reference.type === 'agent-topic') {
const { topicId } = reference.params as AgentTopicParams;
return operationSelectors.isTopicUnreadCompleted(topicId)(s);
}
return false;
});

View file

@ -1,5 +1,6 @@
'use client';
import { useWatchBroadcast } from '@lobechat/electron-client-ipc';
import { ActionIcon, ScrollArea } from '@lobehub/ui';
import { cx } from 'antd-style';
import { Plus } from 'lucide-react';
@ -9,6 +10,7 @@ import { useNavigate } from 'react-router-dom';
import { usePluginContext } from '@/features/Electron/titlebar/RecentlyViewed/hooks/usePluginContext';
import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins';
import { electronSystemService } from '@/services/electron/system';
import { useElectronStore } from '@/store/electron';
import { electronStylish } from '@/styles/electron';
@ -134,6 +136,14 @@ const TabBar = () => {
return pluginRegistry.getNewTabAction(activeReference, pluginCtx);
}, [activeReference, pluginCtx]);
useWatchBroadcast('closeCurrentTabOrWindow', () => {
if (tabs.length > 1 && activeTabId) {
handleClose(activeTabId);
} else {
void electronSystemService.closeWindow();
}
});
const handleNewTab = useCallback(async () => {
if (!newTabAction) return;
let result;
@ -154,6 +164,10 @@ const TabBar = () => {
if (url) startTransition(() => navigate(url));
}, [newTabAction, addTab, pluginCtx, navigate]);
useWatchBroadcast('createNewTab', () => {
void handleNewTab();
});
if (tabs.length === 0) return null;
return (

View file

@ -27,6 +27,18 @@ export const useStyles = createStaticStyles(({ css, cssVar }) => ({
background: ${cssVar.gold};
box-shadow: 0 0 6px ${cssVar.gold};
`,
unreadDot: css`
position: absolute;
inset-block-end: -2px;
inset-inline-end: -2px;
width: 8px;
height: 8px;
border: 1.5px solid ${cssVar.colorBgLayout};
border-radius: 50%;
background: ${cssVar.colorInfo};
`,
container: css`
flex: 1;
min-width: 0;
@ -58,10 +70,10 @@ export const useStyles = createStaticStyles(({ css, cssVar }) => ({
}
`,
tabActive: css`
background-color: ${cssVar.colorFillSecondary};
background-color: ${cssVar.colorBgContainer};
&:hover {
background-color: ${cssVar.colorFill};
background-color: ${cssVar.colorBgContainer};
}
`,
tabIcon: css`

View file

@ -34,6 +34,7 @@ export default {
'tab.closeRightTabs': 'Close Tabs to the Right',
'tab.newTab': 'New Tab',
'tab.running': 'Agent is running',
'tab.unread': 'New message',
'proxy.auth': 'Authentication Required',
'proxy.authDesc': 'If the proxy server requires a username and password',
'proxy.authSettings': 'Authentication Settings',

View file

@ -56,12 +56,12 @@ const GroupItem = memo<GroupItemComponentProps>(({ group, activeTopicId, activeT
<Flexbox horizontal align="center" gap={8} height={24} style={{ overflow: 'hidden' }}>
<Center flex={'none'} height={24} width={28}>
<Icon
color={cssVar.colorTextSecondary}
color={cssVar.colorTextTertiary}
icon={FolderClosedIcon}
size={{ size: 15, strokeWidth: 1.5 }}
/>
</Center>
<Text ellipsis fontSize={14} style={{ flex: 1 }} type={'secondary'}>
<Text ellipsis fontSize={14} style={{ color: cssVar.colorTextSecondary, flex: 1 }}>
{title}
</Text>
</Flexbox>