feat: support custom minimize, maximize and close (#9847)

* feat: add window control functionality for Windows and Linux

- Introduced new IPC channels for window management: minimize, maximize, unmaximize, close, and check maximized state.
- Implemented window control buttons in the UI, allowing users to minimize, maximize, and close the application.
- Enhanced Navbar and TabContainer components to include window controls, improving user experience on non-Mac platforms.
- Styled window control buttons for better visual integration.

This update enhances the application's usability by providing essential window management features.

* add tooltip

* fix macos

* lint error

* update i18n

* lint

* fix: add WindowControls to MinApp popup and improve hover styles

- Add WindowControls component to MinappPopupContainer title bar for Windows/Linux
- Fix ButtonsGroup overlap with WindowControls by adding proper margin
- Improve WindowControls hover background visibility by using rgba(128,128,128,0.3)
- Ensure WindowControls is positioned at the right edge of title bar

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* lint

* add types

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
beyondkmp 2025-09-05 12:30:01 +08:00 committed by GitHub
parent 19846c7e01
commit b1a39e9b38
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 326 additions and 30 deletions

View File

@ -123,6 +123,12 @@ export enum IpcChannel {
Windows_SetMinimumSize = 'window:set-minimum-size',
Windows_Resize = 'window:resize',
Windows_GetSize = 'window:get-size',
Windows_Minimize = 'window:minimize',
Windows_Maximize = 'window:maximize',
Windows_Unmaximize = 'window:unmaximize',
Windows_Close = 'window:close',
Windows_IsMaximized = 'window:is-maximized',
Windows_MaximizedChanged = 'window:maximized-changed',
KnowledgeBase_Create = 'knowledge-base:create',
KnowledgeBase_Reset = 'knowledge-base:reset',

View File

@ -587,6 +587,41 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
return [width, height]
})
// Window Controls
ipcMain.handle(IpcChannel.Windows_Minimize, () => {
checkMainWindow()
mainWindow.minimize()
})
ipcMain.handle(IpcChannel.Windows_Maximize, () => {
checkMainWindow()
mainWindow.maximize()
})
ipcMain.handle(IpcChannel.Windows_Unmaximize, () => {
checkMainWindow()
mainWindow.unmaximize()
})
ipcMain.handle(IpcChannel.Windows_Close, () => {
checkMainWindow()
mainWindow.close()
})
ipcMain.handle(IpcChannel.Windows_IsMaximized, () => {
checkMainWindow()
return mainWindow.isMaximized()
})
// Send maximized state changes to renderer
mainWindow.on('maximize', () => {
mainWindow.webContents.send(IpcChannel.Windows_MaximizedChanged, true)
})
mainWindow.on('unmaximize', () => {
mainWindow.webContents.send(IpcChannel.Windows_MaximizedChanged, false)
})
// VertexAI
ipcMain.handle(IpcChannel.VertexAI_GetAuthHeaders, async (_, params) => {
return vertexAIService.getAuthHeaders(params)

View File

@ -66,11 +66,19 @@ export class WindowService {
transparent: false,
vibrancy: 'sidebar',
visualEffectState: 'active',
titleBarStyle: 'hidden',
titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight,
// For Windows and Linux, we use frameless window with custom controls
// For Mac, we keep the native title bar style
...(isMac
? {
titleBarStyle: 'hidden',
titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight,
trafficLightPosition: { x: 8, y: 13 }
}
: {
frame: false // Frameless window for Windows and Linux
}),
backgroundColor: isMac ? undefined : nativeTheme.shouldUseDarkColors ? '#181818' : '#FFFFFF',
darkTheme: nativeTheme.shouldUseDarkColors,
trafficLightPosition: { x: 8, y: 13 },
...(isLinux ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),

View File

@ -438,6 +438,20 @@ const api = {
cherryin: {
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>
ipcRenderer.invoke(IpcChannel.Cherryin_GetSignature, params)
},
windowControls: {
minimize: (): Promise<void> => ipcRenderer.invoke(IpcChannel.Windows_Minimize),
maximize: (): Promise<void> => ipcRenderer.invoke(IpcChannel.Windows_Maximize),
unmaximize: (): Promise<void> => ipcRenderer.invoke(IpcChannel.Windows_Unmaximize),
close: (): Promise<void> => ipcRenderer.invoke(IpcChannel.Windows_Close),
isMaximized: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.Windows_IsMaximized),
onMaximizedChange: (callback: (isMaximized: boolean) => void): (() => void) => {
const channel = IpcChannel.Windows_MaximizedChanged
ipcRenderer.on(channel, (_, isMaximized: boolean) => callback(isMaximized))
return () => {
ipcRenderer.removeAllListeners(channel)
}
}
}
}

View File

@ -11,6 +11,7 @@ import {
ReloadOutlined
} from '@ant-design/icons'
import { loggerService } from '@logger'
import WindowControls from '@renderer/components/WindowControls'
import { isLinux, isMac, isWin } from '@renderer/config/constant'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useBridge } from '@renderer/hooks/useBridge'
@ -434,7 +435,10 @@ const MinappPopupContainer: React.FC = () => {
</Tooltip>
)}
<Spacer />
<ButtonsGroup className={isWin || isLinux ? 'windows' : ''} isTopNavbar={isTopNavbar}>
<ButtonsGroup
className={isWin || isLinux ? 'windows' : ''}
style={{ marginRight: isWin || isLinux ? '140px' : 0 }}
isTopNavbar={isTopNavbar}>
<Tooltip title={t('minapp.popup.goBack')} mouseEnterDelay={0.8} placement="bottom">
<TitleButton onClick={() => handleGoBack(appInfo.id)}>
<ArrowLeftOutlined />
@ -500,6 +504,11 @@ const MinappPopupContainer: React.FC = () => {
</TitleButton>
</Tooltip>
</ButtonsGroup>
{(isWin || isLinux) && (
<div style={{ position: 'absolute', right: 0, top: 0, height: '100%' }}>
<WindowControls />
</div>
)}
</TitleContainer>
)
}
@ -602,7 +611,6 @@ const ButtonsGroup = styled.div<{ isTopNavbar: boolean }>`
gap: 5px;
-webkit-app-region: no-drag;
&.windows {
margin-right: ${isWin ? '130px' : isLinux ? '100px' : 0};
background-color: var(--color-background-mute);
border-radius: 50px;
padding: 0 3px;

View File

@ -1,7 +1,7 @@
import { PlusOutlined } from '@ant-design/icons'
import { Sortable, useDndReorder } from '@renderer/components/dnd'
import Scrollbar from '@renderer/components/Scrollbar'
import { isLinux, isMac, isWin } from '@renderer/config/constant'
import { isMac } from '@renderer/config/constant'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
@ -39,6 +39,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import MinAppIcon from '../Icons/MinAppIcon'
import WindowControls from '../WindowControls'
interface TabsContainerProps {
children: React.ReactNode
@ -268,6 +269,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
<SettingsButton onClick={handleSettingsClick} $active={activeTabId === 'settings'}>
<Settings size={16} />
</SettingsButton>
<WindowControls />
</RightButtonsContainer>
</TabsBar>
<TabContent>{children}</TabContent>
@ -288,7 +290,7 @@ const TabsBar = styled.div<{ $isFullscreen: boolean }>`
align-items: center;
gap: 5px;
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? '75px' : '15px')};
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : '0')};
height: var(--navbar-height);
position: relative;
-webkit-app-region: drag;
@ -427,6 +429,7 @@ const RightButtonsContainer = styled.div`
align-items: center;
gap: 6px;
margin-left: auto;
padding-right: ${isMac ? '12px' : '0'};
flex-shrink: 0;
`

View File

@ -0,0 +1,42 @@
import styled from 'styled-components'
export const WindowControlsContainer = styled.div`
display: flex;
align-items: center;
height: 100%;
-webkit-app-region: no-drag;
user-select: none;
`
export const ControlButton = styled.button<{ $isClose?: boolean }>`
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: var(--navbar-height);
border: none;
background: transparent;
color: var(--color-text);
cursor: pointer;
outline: none;
transition:
background 0.15s,
color 0.15s;
padding: 0;
position: relative;
border-radius: 0;
&:hover {
background: ${(props) => (props.$isClose ? '#e81123' : 'rgba(128, 128, 128, 0.3)')};
color: ${(props) => (props.$isClose ? '#ffffff' : 'var(--color-text)')};
}
&:active {
background: ${(props) => (props.$isClose ? '#c50e1f' : 'rgba(128, 128, 128, 0.4)')};
color: ${(props) => (props.$isClose ? '#ffffff' : 'var(--color-text)')};
}
svg {
pointer-events: none;
}
`

View File

@ -0,0 +1,80 @@
import { isLinux, isWin } from '@renderer/config/constant'
import { Tooltip } from 'antd'
import { Minus, Square, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ControlButton, WindowControlsContainer } from './WindowControls.styled'
// Custom restore icon - two overlapping squares like Windows
const RestoreIcon: React.FC<{ size?: number }> = ({ size = 14 }) => (
<svg width={size} height={size} viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1">
{/* Back square (top-right) */}
<path d="M 4 2 H 11 V 9 H 9 V 4 H 4 V 2" />
{/* Front square (bottom-left) */}
<rect x="2" y="4" width="7" height="7" />
</svg>
)
const WindowControls: React.FC = () => {
const [isMaximized, setIsMaximized] = useState(false)
const { t } = useTranslation()
useEffect(() => {
// Check initial maximized state
window.api.windowControls.isMaximized().then(setIsMaximized)
// Listen for maximized state changes
const unsubscribe = window.api.windowControls.onMaximizedChange(setIsMaximized)
return () => {
unsubscribe()
}
}, [])
// Only show on Windows and Linux
if (!isWin && !isLinux) {
return null
}
const handleMinimize = () => {
window.api.windowControls.minimize()
}
const handleMaximize = () => {
if (isMaximized) {
window.api.windowControls.unmaximize()
} else {
window.api.windowControls.maximize()
}
}
const handleClose = () => {
window.api.windowControls.close()
}
return (
<WindowControlsContainer>
<Tooltip title={t('navbar.window.minimize')} placement="bottom" mouseEnterDelay={0.2}>
<ControlButton onClick={handleMinimize} aria-label="Minimize">
<Minus size={14} />
</ControlButton>
</Tooltip>
<Tooltip
title={isMaximized ? t('navbar.window.restore') : t('navbar.window.maximize')}
placement="bottom"
mouseEnterDelay={0.2}>
<ControlButton onClick={handleMaximize} aria-label={isMaximized ? 'Restore' : 'Maximize'}>
{isMaximized ? <RestoreIcon size={14} /> : <Square size={14} />}
</ControlButton>
</Tooltip>
<Tooltip title={t('navbar.window.close')} placement="bottom" mouseEnterDelay={0.2}>
<ControlButton $isClose onClick={handleClose} aria-label="Close">
<X size={17} />
</ControlButton>
</Tooltip>
</WindowControlsContainer>
)
}
export default WindowControls

View File

@ -6,6 +6,8 @@ import type { FC, PropsWithChildren } from 'react'
import type { HTMLAttributes } from 'react'
import styled from 'styled-components'
import WindowControls from '../WindowControls'
type Props = PropsWithChildren & HTMLAttributes<HTMLDivElement>
export const Navbar: FC<Props> = ({ children, ...props }) => {
@ -28,7 +30,17 @@ export const NavbarLeft: FC<Props> = ({ children, ...props }) => {
}
export const NavbarCenter: FC<Props> = ({ children, ...props }) => {
return <NavbarCenterContainer {...props}>{children}</NavbarCenterContainer>
return (
<NavbarCenterContainer {...props}>
{children}
{/* Add WindowControls for Windows and Linux in NavbarCenter */}
{(isWin || isLinux) && (
<div style={{ position: 'absolute', right: 0, top: 0, height: '100%', display: 'flex', alignItems: 'center' }}>
<WindowControls />
</div>
)}
</NavbarCenterContainer>
)
}
export const NavbarRight: FC<Props> = ({ children, ...props }) => {
@ -81,6 +93,7 @@ const NavbarCenterContainer = styled.div`
padding: 0 ${isMac ? '20px' : 0};
font-weight: bold;
color: var(--color-text-1);
position: relative;
`
const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`

View File

@ -1653,7 +1653,13 @@
"navbar": {
"expand": "Expand Dialog",
"hide_sidebar": "Hide Sidebar",
"show_sidebar": "Show Sidebar"
"show_sidebar": "Show Sidebar",
"window": {
"close": "Close",
"maximize": "Maximize",
"minimize": "Minimize",
"restore": "Restore"
}
},
"navigate": {
"provider_settings": "Go to provider settings"

View File

@ -1653,7 +1653,13 @@
"navbar": {
"expand": "伸缩对话框",
"hide_sidebar": "隐藏侧边栏",
"show_sidebar": "显示侧边栏"
"show_sidebar": "显示侧边栏",
"window": {
"close": "关闭",
"maximize": "最大化",
"minimize": "最小化",
"restore": "还原"
}
},
"navigate": {
"provider_settings": "跳转到服务商设置界面"

View File

@ -1653,7 +1653,13 @@
"navbar": {
"expand": "伸縮對話框",
"hide_sidebar": "隱藏側邊欄",
"show_sidebar": "顯示側邊欄"
"show_sidebar": "顯示側邊欄",
"window": {
"close": "關閉",
"maximize": "最大化",
"minimize": "最小化",
"restore": "還原"
}
},
"navigate": {
"provider_settings": "跳轉到服務商設置界面"

View File

@ -1653,7 +1653,13 @@
"navbar": {
"expand": "Επισκευή διαλόγου",
"hide_sidebar": "Απόκρυψη πλάγιας μπάρας",
"show_sidebar": "Εμφάνιση πλάγιας μπάρας"
"show_sidebar": "Εμφάνιση πλάγιας μπάρας",
"window": {
"close": "Κλείσιμο",
"maximize": "Μεγιστοποίηση",
"minimize": "Ελαχιστοποίηση",
"restore": "Επαναφορά"
}
},
"navigate": {
"provider_settings": "Μετάβαση στις ρυθμίσεις παρόχου"

View File

@ -1653,7 +1653,13 @@
"navbar": {
"expand": "Expandir cuadro de diálogo",
"hide_sidebar": "Ocultar barra lateral",
"show_sidebar": "Mostrar barra lateral"
"show_sidebar": "Mostrar barra lateral",
"window": {
"close": "Cerrar",
"maximize": "Maximizar",
"minimize": "Minimizar",
"restore": "Restaurar"
}
},
"navigate": {
"provider_settings": "Ir a la configuración del proveedor"

View File

@ -1653,7 +1653,13 @@
"navbar": {
"expand": "Agrandir la boîte de dialogue",
"hide_sidebar": "Cacher la barre latérale",
"show_sidebar": "Afficher la barre latérale"
"show_sidebar": "Afficher la barre latérale",
"window": {
"close": "Fermer",
"maximize": "Agrandir",
"minimize": "Réduire",
"restore": "Restaurer"
}
},
"navigate": {
"provider_settings": "Aller aux paramètres du fournisseur"

View File

@ -1653,7 +1653,13 @@
"navbar": {
"expand": "ダイアログを展開",
"hide_sidebar": "サイドバーを非表示",
"show_sidebar": "サイドバーを表示"
"show_sidebar": "サイドバーを表示",
"window": {
"close": "閉じる",
"maximize": "最大化",
"minimize": "最小化",
"restore": "元に戻す"
}
},
"navigate": {
"provider_settings": "プロバイダー設定に移動"

View File

@ -1653,7 +1653,13 @@
"navbar": {
"expand": "Expandir caixa de diálogo",
"hide_sidebar": "Ocultar barra lateral",
"show_sidebar": "Mostrar barra lateral"
"show_sidebar": "Mostrar barra lateral",
"window": {
"close": "Fechar",
"maximize": "Maximizar",
"minimize": "Minimizar",
"restore": "Restaurar"
}
},
"navigate": {
"provider_settings": "Ir para as configurações do provedor"

View File

@ -1653,7 +1653,13 @@
"navbar": {
"expand": "Развернуть диалоговое окно",
"hide_sidebar": "Скрыть боковую панель",
"show_sidebar": "Показать боковую панель"
"show_sidebar": "Показать боковую панель",
"window": {
"close": "Закрыть",
"maximize": "Развернуть",
"minimize": "Свернуть",
"restore": "Восстановить"
}
},
"navigate": {
"provider_settings": "Перейти к настройкам поставщика"

View File

@ -1,7 +1,8 @@
import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
import { HStack } from '@renderer/components/Layout'
import SearchPopup from '@renderer/components/Popups/SearchPopup'
import { isMac } from '@renderer/config/constant'
import WindowControls from '@renderer/components/WindowControls'
import { isLinux, isMac, isWin } from '@renderer/config/constant'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
import { modelGenerating } from '@renderer/hooks/useRuntime'
@ -87,7 +88,9 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
</motion.div>
)}
</AnimatePresence>
<NavbarRight style={{ justifyContent: 'space-between', flex: 1 }} className="home-navbar-right">
<NavbarRight
style={{ justifyContent: 'space-between', flex: 1, position: 'relative' }}
className="home-navbar-right">
<HStack alignItems="center">
{!showAssistants && (
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8}>
@ -114,18 +117,8 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
</AnimatePresence>
<SelectModelButton assistant={assistant} />
</HStack>
<HStack alignItems="center" gap={8}>
<HStack alignItems="center" gap={6}>
<UpdateAppButton />
<Tooltip title={t('chat.assistant.search.placeholder')} mouseEnterDelay={0.8}>
<NarrowIcon onClick={() => SearchPopup.show()}>
<Search size={18} />
</NarrowIcon>
</Tooltip>
<Tooltip title={t('navbar.expand')} mouseEnterDelay={0.8}>
<NarrowIcon onClick={handleNarrowModeToggle}>
<i className="iconfont icon-icon-adaptive-width"></i>
</NarrowIcon>
</Tooltip>
{topicPosition === 'right' && !showTopics && (
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={2}>
<NavbarIcon onClick={toggleShowTopics}>
@ -140,7 +133,47 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
</NavbarIcon>
</Tooltip>
)}
{/* For Mac, show search and expand without WindowControls */}
{!isWin && !isLinux && (
<>
<Tooltip title={t('chat.assistant.search.placeholder')} mouseEnterDelay={0.8}>
<NarrowIcon onClick={() => SearchPopup.show()}>
<Search size={18} />
</NarrowIcon>
</Tooltip>
<Tooltip title={t('navbar.expand')} mouseEnterDelay={0.8}>
<NarrowIcon onClick={handleNarrowModeToggle}>
<i className="iconfont icon-icon-adaptive-width"></i>
</NarrowIcon>
</Tooltip>
</>
)}
</HStack>
{/* Search, Expand and WindowControls positioned at the right edge */}
{(isWin || isLinux) && (
<div
style={{
position: 'absolute',
right: 0,
top: 0,
height: '100%',
display: 'flex',
alignItems: 'center',
gap: 6
}}>
<Tooltip title={t('chat.assistant.search.placeholder')} mouseEnterDelay={0.8}>
<NavbarIcon onClick={() => SearchPopup.show()}>
<Search size={18} />
</NavbarIcon>
</Tooltip>
<Tooltip title={t('navbar.expand')} mouseEnterDelay={0.8}>
<NavbarIcon onClick={handleNarrowModeToggle}>
<i className="iconfont icon-icon-adaptive-width"></i>
</NavbarIcon>
</Tooltip>
<WindowControls />
</div>
)}
</NavbarRight>
</Navbar>
)