From ed64e2b8af6efdc420427c960d74b2c34b84482d Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Mon, 20 Apr 2026 12:38:54 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(electron):=20add=20`Cmd+W/Cmd+?= =?UTF-8?q?T`=20tab=20shortcuts=20with=20misc=20desktop=20polish=20(#13983?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 💄 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) * 💄 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) * ✨ 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) * 🐛 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) * ✨ 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) * ✨ 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) * 💄 style(electron): use container color for active tab background Co-Authored-By: Claude Opus 4.7 (1M context) * ✅ 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) * 🐛 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../desktop/resources/locales/zh-CN/menu.json | 1 + .../main/controllers/HeterogeneousAgentCtr.ts | 7 +- .../__tests__/HeterogeneousAgentCtr.test.ts | 15 +++- apps/desktop/src/main/locales/default/menu.ts | 1 + .../src/main/menus/impls/linux.test.ts | 6 +- apps/desktop/src/main/menus/impls/linux.ts | 26 +++++- apps/desktop/src/main/menus/impls/macOS.ts | 26 +++++- .../src/main/menus/impls/windows.test.ts | 4 +- apps/desktop/src/main/menus/impls/windows.ts | 26 +++++- .../networkProxy/__tests__/envBuilder.test.ts | 81 +++++++++++++++++++ .../main/modules/networkProxy/envBuilder.ts | 36 +++++++++ .../src/main/modules/networkProxy/index.ts | 1 + .../src/events/navigation.ts | 12 +++ .../Electron/titlebar/TabBar/TabItem.tsx | 7 ++ .../titlebar/TabBar/hooks/useTabUnread.ts | 24 ++++++ .../Electron/titlebar/TabBar/index.tsx | 14 ++++ .../Electron/titlebar/TabBar/styles.ts | 16 +++- src/locales/default/electron.ts | 1 + .../ByProjectMode/GroupItem.tsx | 4 +- 19 files changed, 291 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/src/main/modules/networkProxy/__tests__/envBuilder.test.ts create mode 100644 apps/desktop/src/main/modules/networkProxy/envBuilder.ts create mode 100644 src/features/Electron/titlebar/TabBar/hooks/useTabUnread.ts diff --git a/apps/desktop/resources/locales/zh-CN/menu.json b/apps/desktop/resources/locales/zh-CN/menu.json index 2bcea5051e..866334c743 100644 --- a/apps/desktop/resources/locales/zh-CN/menu.json +++ b/apps/desktop/resources/locales/zh-CN/menu.json @@ -48,6 +48,7 @@ "file.newAgent": "新建助手", "file.newAgentGroup": "新建助手组", "file.newPage": "新建页面", + "file.newTab": "新建标签页", "file.newTopic": "新建话题", "file.preferences": "设置…", "file.quit": "退出", diff --git a/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts b/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts index afe590967b..104ac6262e 100644 --- a/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts +++ b/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts @@ -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'], }); diff --git a/apps/desktop/src/main/controllers/__tests__/HeterogeneousAgentCtr.test.ts b/apps/desktop/src/main/controllers/__tests__/HeterogeneousAgentCtr.test.ts index 6a2d08c6a2..b69c695e7c 100644 --- a/apps/desktop/src/main/controllers/__tests__/HeterogeneousAgentCtr.test.ts +++ b/apps/desktop/src/main/controllers/__tests__/HeterogeneousAgentCtr.test.ts @@ -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', diff --git a/apps/desktop/src/main/locales/default/menu.ts b/apps/desktop/src/main/locales/default/menu.ts index 2b6382100c..eb94e4adbf 100644 --- a/apps/desktop/src/main/locales/default/menu.ts +++ b/apps/desktop/src/main/locales/default/menu.ts @@ -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', diff --git a/apps/desktop/src/main/menus/impls/linux.test.ts b/apps/desktop/src/main/menus/impls/linux.test.ts index f0d01adebc..9eb194b3f3 100644 --- a/apps/desktop/src/main/menus/impls/linux.test.ts +++ b/apps/desktop/src/main/menus/impls/linux.test.ts @@ -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)', () => { diff --git a/apps/desktop/src/main/menus/impls/linux.ts b/apps/desktop/src/main/menus/impls/linux.ts index c40cc21589..afc5e2742e 100644 --- a/apps/desktop/src/main/menus/impls/linux.ts +++ b/apps/desktop/src/main/menus/impls/linux.ts @@ -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' }, diff --git a/apps/desktop/src/main/menus/impls/macOS.ts b/apps/desktop/src/main/menus/impls/macOS.ts index 3d7e8bfc0f..119dd240ed 100644 --- a/apps/desktop/src/main/menus/impls/macOS.ts +++ b/apps/desktop/src/main/menus/impls/macOS.ts @@ -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'), + }, ], }, { diff --git a/apps/desktop/src/main/menus/impls/windows.test.ts b/apps/desktop/src/main/menus/impls/windows.test.ts index d097887a75..b0f3088cca 100644 --- a/apps/desktop/src/main/menus/impls/windows.test.ts +++ b/apps/desktop/src/main/menus/impls/windows.test.ts @@ -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', () => { diff --git a/apps/desktop/src/main/menus/impls/windows.ts b/apps/desktop/src/main/menus/impls/windows.ts index fab6764ba3..14b758f2fa 100644 --- a/apps/desktop/src/main/menus/impls/windows.ts +++ b/apps/desktop/src/main/menus/impls/windows.ts @@ -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'), + }, ], }, { diff --git a/apps/desktop/src/main/modules/networkProxy/__tests__/envBuilder.test.ts b/apps/desktop/src/main/modules/networkProxy/__tests__/envBuilder.test.ts new file mode 100644 index 0000000000..bab5253615 --- /dev/null +++ b/apps/desktop/src/main/modules/networkProxy/__tests__/envBuilder.test.ts @@ -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'); + }); +}); diff --git a/apps/desktop/src/main/modules/networkProxy/envBuilder.ts b/apps/desktop/src/main/modules/networkProxy/envBuilder.ts new file mode 100644 index 0000000000..df3fa6b8f7 --- /dev/null +++ b/apps/desktop/src/main/modules/networkProxy/envBuilder.ts @@ -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 => { + if (!config?.enableProxy || !config.proxyServer || !config.proxyPort) { + return {}; + } + + const url = ProxyUrlBuilder.build(config); + const env: Record = {}; + + // 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; +}; diff --git a/apps/desktop/src/main/modules/networkProxy/index.ts b/apps/desktop/src/main/modules/networkProxy/index.ts index 0c783d4460..1d70f1fdcf 100644 --- a/apps/desktop/src/main/modules/networkProxy/index.ts +++ b/apps/desktop/src/main/modules/networkProxy/index.ts @@ -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'; diff --git a/packages/electron-client-ipc/src/events/navigation.ts b/packages/electron-client-ipc/src/events/navigation.ts index fc9d689a5d..cef8b99269 100644 --- a/packages/electron-client-ipc/src/events/navigation.ts +++ b/packages/electron-client-ipc/src/events/navigation.ts @@ -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. diff --git a/src/features/Electron/titlebar/TabBar/TabItem.tsx b/src/features/Electron/titlebar/TabBar/TabItem.tsx index 28229f3500..725848ca8c 100644 --- a/src/features/Electron/titlebar/TabBar/TabItem.tsx +++ b/src/features/Electron/titlebar/TabBar/TabItem.tsx @@ -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( 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( size={16} /> {isRunning && } + {showUnreadDot && } ) : ( item.icon && ( {isRunning && } + {showUnreadDot && ( + + )} ) )} diff --git a/src/features/Electron/titlebar/TabBar/hooks/useTabUnread.ts b/src/features/Electron/titlebar/TabBar/hooks/useTabUnread.ts new file mode 100644 index 0000000000..ffbd4f4238 --- /dev/null +++ b/src/features/Electron/titlebar/TabBar/hooks/useTabUnread.ts @@ -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; + }); diff --git a/src/features/Electron/titlebar/TabBar/index.tsx b/src/features/Electron/titlebar/TabBar/index.tsx index ee4b03dad7..089fc86616 100644 --- a/src/features/Electron/titlebar/TabBar/index.tsx +++ b/src/features/Electron/titlebar/TabBar/index.tsx @@ -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 ( diff --git a/src/features/Electron/titlebar/TabBar/styles.ts b/src/features/Electron/titlebar/TabBar/styles.ts index 045b294653..bdb0debf3a 100644 --- a/src/features/Electron/titlebar/TabBar/styles.ts +++ b/src/features/Electron/titlebar/TabBar/styles.ts @@ -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` diff --git a/src/locales/default/electron.ts b/src/locales/default/electron.ts index cf5847272a..7622ce741b 100644 --- a/src/locales/default/electron.ts +++ b/src/locales/default/electron.ts @@ -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', diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/GroupItem.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/GroupItem.tsx index 29c9cc981a..8dfa17595b 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/GroupItem.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/GroupItem.tsx @@ -56,12 +56,12 @@ const GroupItem = memo(({ group, activeTopicId, activeT
- + {title}