mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
✨ 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:
parent
e7236c0169
commit
ed64e2b8af
19 changed files with 291 additions and 17 deletions
|
|
@ -48,6 +48,7 @@
|
||||||
"file.newAgent": "新建助手",
|
"file.newAgent": "新建助手",
|
||||||
"file.newAgentGroup": "新建助手组",
|
"file.newAgentGroup": "新建助手组",
|
||||||
"file.newPage": "新建页面",
|
"file.newPage": "新建页面",
|
||||||
|
"file.newTab": "新建标签页",
|
||||||
"file.newTopic": "新建话题",
|
"file.newTopic": "新建话题",
|
||||||
"file.preferences": "设置…",
|
"file.preferences": "设置…",
|
||||||
"file.quit": "退出",
|
"file.quit": "退出",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { Readable, Writable } from 'node:stream';
|
||||||
|
|
||||||
import { app as electronApp, BrowserWindow } from 'electron';
|
import { app as electronApp, BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
import { buildProxyEnv } from '@/modules/networkProxy/envBuilder';
|
||||||
import { createLogger } from '@/utils/logger';
|
import { createLogger } from '@/utils/logger';
|
||||||
|
|
||||||
import { ControllerModule, IpcMethod } from './index';
|
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 claude binary can leave bash/grep/etc. tool children running and
|
||||||
// the CLI hung waiting on them. Windows has different semantics — use
|
// the CLI hung waiting on them. Windows has different semantics — use
|
||||||
// taskkill /T /F there; no detached flag needed.
|
// 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, {
|
const proc = spawn(session.command, cliArgs, {
|
||||||
cwd,
|
cwd,
|
||||||
detached: process.platform !== 'win32',
|
detached: process.platform !== 'win32',
|
||||||
env: { ...process.env, ...session.env },
|
env: { ...process.env, ...proxyEnv, ...session.env },
|
||||||
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,10 @@ describe('HeterogeneousAgentCtr', () => {
|
||||||
|
|
||||||
describe('resolveImage', () => {
|
describe('resolveImage', () => {
|
||||||
it('stores traversal-looking ids inside the cache root via a stable hash key', async () => {
|
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 cacheDir = path.join(appStoragePath, 'heteroAgent/files');
|
||||||
const escapedTargetName = `${path.basename(appStoragePath)}-outside-storage`;
|
const escapedTargetName = `${path.basename(appStoragePath)}-outside-storage`;
|
||||||
const escapePath = path.join(cacheDir, `../../../${escapedTargetName}`);
|
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 () => {
|
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 cacheDir = path.join(appStoragePath, 'heteroAgent/files');
|
||||||
const traversalId = '../../preexisting-secret';
|
const traversalId = '../../preexisting-secret';
|
||||||
const outOfRootDataPath = path.join(cacheDir, traversalId);
|
const outOfRootDataPath = path.join(cacheDir, traversalId);
|
||||||
|
|
@ -144,7 +150,10 @@ describe('HeterogeneousAgentCtr', () => {
|
||||||
const { proc, writes } = createFakeProc();
|
const { proc, writes } = createFakeProc();
|
||||||
nextFakeProc = proc;
|
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({
|
const { sessionId } = await ctr.startSession({
|
||||||
agentType: 'claude-code',
|
agentType: 'claude-code',
|
||||||
command: 'claude',
|
command: 'claude',
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ const menu = {
|
||||||
'file.newAgent': 'New Agent',
|
'file.newAgent': 'New Agent',
|
||||||
'file.newAgentGroup': 'New Agent Group',
|
'file.newAgentGroup': 'New Agent Group',
|
||||||
'file.newPage': 'New Page',
|
'file.newPage': 'New Page',
|
||||||
|
'file.newTab': 'New Tab',
|
||||||
'file.newTopic': 'New Topic',
|
'file.newTopic': 'New Topic',
|
||||||
'file.preferences': 'Preferences',
|
'file.preferences': 'Preferences',
|
||||||
'file.quit': 'Quit',
|
'file.quit': 'Quit',
|
||||||
|
|
|
||||||
|
|
@ -309,14 +309,16 @@ describe('LinuxMenu', () => {
|
||||||
expect(copyItem.role).toBe('copy');
|
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();
|
linuxMenu.buildAndSetAppMenu();
|
||||||
|
|
||||||
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
|
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
|
||||||
const fileMenu = template.find((item: any) => item.label === 'File');
|
const fileMenu = template.find((item: any) => item.label === 'File');
|
||||||
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
|
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)', () => {
|
it('should use role for minimize (accelerator handled by Electron)', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { MenuItemConstructorOptions } from 'electron';
|
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';
|
import { isDev } from '@/const/env';
|
||||||
|
|
||||||
|
|
@ -64,6 +64,15 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||||
},
|
},
|
||||||
label: t('file.newTopic'),
|
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' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
accelerator: 'Alt+Ctrl+A',
|
accelerator: 'Alt+Ctrl+A',
|
||||||
|
|
@ -104,7 +113,20 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||||
label: t('common.checkUpdates'),
|
label: t('common.checkUpdates'),
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ 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' },
|
{ label: t('window.minimize'), role: 'minimize' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ label: t('file.quit'), role: 'quit' },
|
{ label: t('file.quit'), role: 'quit' },
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
||||||
import type { MenuItemConstructorOptions } from 'electron';
|
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 { isDev } from '@/const/env';
|
||||||
import NotificationCtr from '@/controllers/NotificationCtr';
|
import NotificationCtr from '@/controllers/NotificationCtr';
|
||||||
|
|
@ -116,6 +116,15 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||||
},
|
},
|
||||||
label: t('file.newTopic'),
|
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' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
accelerator: 'Alt+Command+A',
|
accelerator: 'Alt+Command+A',
|
||||||
|
|
@ -145,7 +154,20 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||||
label: t('file.newPage'),
|
label: t('file.newPage'),
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ 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'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -398,10 +398,12 @@ describe('WindowsMenu', () => {
|
||||||
const windowMenu = template.find((item: any) => item.label === 'Window');
|
const windowMenu = template.find((item: any) => item.label === 'Window');
|
||||||
|
|
||||||
const minimizeItem = windowMenu.submenu.find((item: any) => item.role === 'minimize');
|
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(minimizeItem).toBeDefined();
|
||||||
expect(closeItem).toBeDefined();
|
expect(closeItem).toBeDefined();
|
||||||
|
expect(closeItem.accelerator).toBe('CmdOrCtrl+W');
|
||||||
|
expect(typeof closeItem.click).toBe('function');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have zoom controls in view menu', () => {
|
it('should have zoom controls in view menu', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { MenuItemConstructorOptions } from 'electron';
|
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 { isDev } from '@/const/env';
|
||||||
|
|
||||||
|
|
@ -63,6 +63,15 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||||
},
|
},
|
||||||
label: t('file.newTopic'),
|
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' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
accelerator: 'Alt+Ctrl+A',
|
accelerator: 'Alt+Ctrl+A',
|
||||||
|
|
@ -167,7 +176,20 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||||
label: t('window.title'),
|
label: t('window.title'),
|
||||||
submenu: [
|
submenu: [
|
||||||
{ label: t('window.minimize'), role: 'minimize' },
|
{ 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'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
36
apps/desktop/src/main/modules/networkProxy/envBuilder.ts
Normal file
36
apps/desktop/src/main/modules/networkProxy/envBuilder.ts
Normal 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;
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export { ProxyDispatcherManager } from './dispatcher';
|
export { ProxyDispatcherManager } from './dispatcher';
|
||||||
|
export { buildProxyEnv } from './envBuilder';
|
||||||
export type { ProxyTestResult } from './tester';
|
export type { ProxyTestResult } from './tester';
|
||||||
export { ProxyConnectionTester } from './tester';
|
export { ProxyConnectionTester } from './tester';
|
||||||
export { ProxyUrlBuilder } from './urlBuilder';
|
export { ProxyUrlBuilder } from './urlBuilder';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
export interface NavigationBroadcastEvents {
|
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.
|
* Ask renderer to create a new agent.
|
||||||
* Triggered from the main process File menu.
|
* Triggered from the main process File menu.
|
||||||
|
|
@ -17,6 +23,12 @@ export interface NavigationBroadcastEvents {
|
||||||
*/
|
*/
|
||||||
createNewPage: () => void;
|
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).
|
* Ask renderer to create a new topic (start a new conversation).
|
||||||
* Triggered from the main process File menu.
|
* Triggered from the main process File menu.
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { type ResolvedPageData } from '@/features/Electron/titlebar/RecentlyView
|
||||||
import { electronStylish } from '@/styles/electron';
|
import { electronStylish } from '@/styles/electron';
|
||||||
|
|
||||||
import { useTabRunning } from './hooks/useTabRunning';
|
import { useTabRunning } from './hooks/useTabRunning';
|
||||||
|
import { useTabUnread } from './hooks/useTabUnread';
|
||||||
import { useStyles } from './styles';
|
import { useStyles } from './styles';
|
||||||
|
|
||||||
interface TabItemProps {
|
interface TabItemProps {
|
||||||
|
|
@ -47,6 +48,8 @@ const TabItem = memo<TabItemProps>(
|
||||||
const { t } = useTranslation('electron');
|
const { t } = useTranslation('electron');
|
||||||
const id = item.reference.id;
|
const id = item.reference.id;
|
||||||
const isRunning = useTabRunning(item.reference);
|
const isRunning = useTabRunning(item.reference);
|
||||||
|
const isUnread = useTabUnread(item.reference);
|
||||||
|
const showUnreadDot = !isRunning && isUnread;
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
|
|
@ -110,12 +113,16 @@ const TabItem = memo<TabItemProps>(
|
||||||
size={16}
|
size={16}
|
||||||
/>
|
/>
|
||||||
{isRunning && <span aria-label={t('tab.running')} className={styles.runningDot} />}
|
{isRunning && <span aria-label={t('tab.running')} className={styles.runningDot} />}
|
||||||
|
{showUnreadDot && <span aria-label={t('tab.unread')} className={styles.unreadDot} />}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
item.icon && (
|
item.icon && (
|
||||||
<span className={styles.avatarWrapper}>
|
<span className={styles.avatarWrapper}>
|
||||||
<Icon className={styles.tabIcon} icon={item.icon} size="small" />
|
<Icon className={styles.tabIcon} icon={item.icon} size="small" />
|
||||||
{isRunning && <span aria-label={t('tab.running')} className={styles.runningDot} />}
|
{isRunning && <span aria-label={t('tab.running')} className={styles.runningDot} />}
|
||||||
|
{showUnreadDot && (
|
||||||
|
<span aria-label={t('tab.unread')} className={styles.unreadDot} />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
24
src/features/Electron/titlebar/TabBar/hooks/useTabUnread.ts
Normal file
24
src/features/Electron/titlebar/TabBar/hooks/useTabUnread.ts
Normal 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;
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useWatchBroadcast } from '@lobechat/electron-client-ipc';
|
||||||
import { ActionIcon, ScrollArea } from '@lobehub/ui';
|
import { ActionIcon, ScrollArea } from '@lobehub/ui';
|
||||||
import { cx } from 'antd-style';
|
import { cx } from 'antd-style';
|
||||||
import { Plus } from 'lucide-react';
|
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 { usePluginContext } from '@/features/Electron/titlebar/RecentlyViewed/hooks/usePluginContext';
|
||||||
import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins';
|
import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins';
|
||||||
|
import { electronSystemService } from '@/services/electron/system';
|
||||||
import { useElectronStore } from '@/store/electron';
|
import { useElectronStore } from '@/store/electron';
|
||||||
import { electronStylish } from '@/styles/electron';
|
import { electronStylish } from '@/styles/electron';
|
||||||
|
|
||||||
|
|
@ -134,6 +136,14 @@ const TabBar = () => {
|
||||||
return pluginRegistry.getNewTabAction(activeReference, pluginCtx);
|
return pluginRegistry.getNewTabAction(activeReference, pluginCtx);
|
||||||
}, [activeReference, pluginCtx]);
|
}, [activeReference, pluginCtx]);
|
||||||
|
|
||||||
|
useWatchBroadcast('closeCurrentTabOrWindow', () => {
|
||||||
|
if (tabs.length > 1 && activeTabId) {
|
||||||
|
handleClose(activeTabId);
|
||||||
|
} else {
|
||||||
|
void electronSystemService.closeWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const handleNewTab = useCallback(async () => {
|
const handleNewTab = useCallback(async () => {
|
||||||
if (!newTabAction) return;
|
if (!newTabAction) return;
|
||||||
let result;
|
let result;
|
||||||
|
|
@ -154,6 +164,10 @@ const TabBar = () => {
|
||||||
if (url) startTransition(() => navigate(url));
|
if (url) startTransition(() => navigate(url));
|
||||||
}, [newTabAction, addTab, pluginCtx, navigate]);
|
}, [newTabAction, addTab, pluginCtx, navigate]);
|
||||||
|
|
||||||
|
useWatchBroadcast('createNewTab', () => {
|
||||||
|
void handleNewTab();
|
||||||
|
});
|
||||||
|
|
||||||
if (tabs.length === 0) return null;
|
if (tabs.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,18 @@ export const useStyles = createStaticStyles(({ css, cssVar }) => ({
|
||||||
background: ${cssVar.gold};
|
background: ${cssVar.gold};
|
||||||
box-shadow: 0 0 6px ${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`
|
container: css`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -58,10 +70,10 @@ export const useStyles = createStaticStyles(({ css, cssVar }) => ({
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
tabActive: css`
|
tabActive: css`
|
||||||
background-color: ${cssVar.colorFillSecondary};
|
background-color: ${cssVar.colorBgContainer};
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: ${cssVar.colorFill};
|
background-color: ${cssVar.colorBgContainer};
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
tabIcon: css`
|
tabIcon: css`
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ export default {
|
||||||
'tab.closeRightTabs': 'Close Tabs to the Right',
|
'tab.closeRightTabs': 'Close Tabs to the Right',
|
||||||
'tab.newTab': 'New Tab',
|
'tab.newTab': 'New Tab',
|
||||||
'tab.running': 'Agent is running',
|
'tab.running': 'Agent is running',
|
||||||
|
'tab.unread': 'New message',
|
||||||
'proxy.auth': 'Authentication Required',
|
'proxy.auth': 'Authentication Required',
|
||||||
'proxy.authDesc': 'If the proxy server requires a username and password',
|
'proxy.authDesc': 'If the proxy server requires a username and password',
|
||||||
'proxy.authSettings': 'Authentication Settings',
|
'proxy.authSettings': 'Authentication Settings',
|
||||||
|
|
|
||||||
|
|
@ -56,12 +56,12 @@ const GroupItem = memo<GroupItemComponentProps>(({ group, activeTopicId, activeT
|
||||||
<Flexbox horizontal align="center" gap={8} height={24} style={{ overflow: 'hidden' }}>
|
<Flexbox horizontal align="center" gap={8} height={24} style={{ overflow: 'hidden' }}>
|
||||||
<Center flex={'none'} height={24} width={28}>
|
<Center flex={'none'} height={24} width={28}>
|
||||||
<Icon
|
<Icon
|
||||||
color={cssVar.colorTextSecondary}
|
color={cssVar.colorTextTertiary}
|
||||||
icon={FolderClosedIcon}
|
icon={FolderClosedIcon}
|
||||||
size={{ size: 15, strokeWidth: 1.5 }}
|
size={{ size: 15, strokeWidth: 1.5 }}
|
||||||
/>
|
/>
|
||||||
</Center>
|
</Center>
|
||||||
<Text ellipsis fontSize={14} style={{ flex: 1 }} type={'secondary'}>
|
<Text ellipsis fontSize={14} style={{ color: cssVar.colorTextSecondary, flex: 1 }}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
</Flexbox>
|
</Flexbox>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue