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.newAgentGroup": "新建助手组",
|
||||
"file.newPage": "新建页面",
|
||||
"file.newTab": "新建标签页",
|
||||
"file.newTopic": "新建话题",
|
||||
"file.preferences": "设置…",
|
||||
"file.quit": "退出",
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 { buildProxyEnv } from './envBuilder';
|
||||
export type { ProxyTestResult } from './tester';
|
||||
export { ProxyConnectionTester } from './tester';
|
||||
export { ProxyUrlBuilder } from './urlBuilder';
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
)}
|
||||
|
|
|
|||
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';
|
||||
|
||||
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 (
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue