feat: improve desktop onboarding window management and footer actions (#11619)

*  feat: improve desktop onboarding window management and footer actions

- Add APP_WINDOW_MIN_SIZE constant for consistent window constraints
- Extract reusable OnboardingFooterActions component for step navigation
- Implement setWindowMinimumSize API in electron system service
- Apply dedicated minimum size (1200x900) during onboarding flow
- Restore app-level defaults (860x500) when onboarding completes
- Add windowMinimumSize parameter support in BrowserManager

Resolves: LOBE-3643, LOBE-3225, LOBE-2588

* chore: update .gitignore to include pnpm-lock.yaml and remove pnpm-lock.yaml file

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-01-19 21:27:30 +08:00
committed by GitHub
parent abf57c59a0
commit 6ed280e0cc
14 changed files with 198 additions and 74 deletions

2
.gitignore vendored
View File

@@ -117,3 +117,5 @@ e2e/reports
out
i18n-unused-keys-report.json
.vitest-reports
pnpm-lock.yaml

View File

@@ -1,3 +1,5 @@
import { APP_WINDOW_MIN_SIZE } from '@lobechat/desktop-bridge';
import type { BrowserWindowOpts } from './core/browser/Browser';
export const BrowsersIdentifiers = {
@@ -11,7 +13,8 @@ export const appBrowsers = {
height: 800,
identifier: 'app',
keepAlive: true,
minWidth: 400,
minHeight: APP_WINDOW_MIN_SIZE.height,
minWidth: APP_WINDOW_MIN_SIZE.width,
path: '/',
showOnInit: true,
titleBarStyle: 'hidden',

View File

@@ -1,7 +1,7 @@
import type {
InterceptRouteParams,
OpenSettingsWindowOptions,
WindowResizableParams,
WindowMinimumSizeParams,
WindowSizeParams,
} from '@lobechat/electron-client-ipc';
import { findMatchingRoute } from '~common/routes';
@@ -81,9 +81,21 @@ export default class BrowserWindowsCtr extends ControllerModule {
}
@IpcMethod()
setWindowResizable(params: WindowResizableParams) {
setWindowMinimumSize(params: WindowMinimumSizeParams) {
this.withSenderIdentifier((identifier) => {
this.app.browserManager.setWindowResizable(identifier, params.resizable);
const currentSize = this.app.browserManager.getWindowSize(identifier);
const nextWindowSize = {
...currentSize,
};
if (params.height) {
nextWindowSize.height = Math.max(currentSize.height, params.height);
}
if (params.width) {
nextWindowSize.width = Math.max(currentSize.width, params.width);
}
this.app.browserManager.setWindowSize(identifier, nextWindowSize);
this.app.browserManager.setWindowMinimumSize(identifier, params);
});
}

View File

@@ -1,4 +1,4 @@
import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
import { APP_WINDOW_MIN_SIZE, TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
import {
BrowserWindow,
@@ -291,9 +291,19 @@ export default class Browser {
});
}
setWindowResizable(resizable: boolean): void {
logger.debug(`[${this.identifier}] Setting window resizable: ${resizable}`);
this._browserWindow?.setResizable(resizable);
setWindowMinimumSize(size: { height?: number; width?: number }): void {
logger.debug(`[${this.identifier}] Setting window minimum size: ${JSON.stringify(size)}`);
const currentMinimumSize = this._browserWindow?.getMinimumSize?.() ?? [0, 0];
const rawWidth = size.width ?? currentMinimumSize[0];
const rawHeight = size.height ?? currentMinimumSize[1];
// Electron doesn't "reset" minimum size with 0x0 reliably.
// Treat 0 / negative as fallback to app-level default preset.
const width = rawWidth > 0 ? rawWidth : APP_WINDOW_MIN_SIZE.width;
const height = rawHeight > 0 ? rawHeight : APP_WINDOW_MIN_SIZE.height;
this._browserWindow?.setMinimumSize?.(width, height);
}
// ==================== Window Position ====================

View File

@@ -250,9 +250,14 @@ export class BrowserManager {
browser?.setWindowSize(size);
}
setWindowResizable(identifier: string, resizable: boolean) {
getWindowSize(identifier: string) {
const browser = this.browsers.get(identifier);
browser?.setWindowResizable(resizable);
return browser?.browserWindow.getBounds();
}
setWindowMinimumSize(identifier: string, size: { height?: number; width?: number }) {
const browser = this.browsers.get(identifier);
browser?.setWindowMinimumSize(size);
}
getIdentifierByWebContents(webContents: WebContents): string | null {

View File

@@ -10,3 +10,8 @@ export {
// Desktop window constants
export const TITLE_BAR_HEIGHT = 38;
export const APP_WINDOW_MIN_SIZE = {
height: 600,
width: 1000,
} as const;

View File

@@ -3,6 +3,7 @@ export interface WindowSizeParams {
width?: number;
}
export interface WindowResizableParams {
resizable: boolean;
export interface WindowMinimumSizeParams {
height?: number;
width?: number;
}

View File

@@ -3,7 +3,7 @@
import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
import { Center, Flexbox, Text } from '@lobehub/ui';
import { Divider } from 'antd';
import { cx } from 'antd-style';
import { css, cx } from 'antd-style';
import type { FC, PropsWithChildren } from 'react';
import SimpleTitleBar from '@/features/Electron/titlebar/SimpleTitleBar';
@@ -13,6 +13,9 @@ import { useIsDark } from '@/hooks/useIsDark';
import { styles } from './style';
const contentContainer = css`
overflow: auto;
`;
const OnboardingContainer: FC<PropsWithChildren> = ({ children }) => {
const isDarkMode = useIsDark();
return (
@@ -44,9 +47,9 @@ const OnboardingContainer: FC<PropsWithChildren> = ({ children }) => {
<ThemeButton placement={'bottomRight'} size={18} />
</Flexbox>
</Flexbox>
<Center height={'100%'} padding={16} width={'100%'}>
<Flexbox align={'center'} className={cx(contentContainer)} height={'100%'} width={'100%'}>
{children}
</Center>
</Flexbox>
<Center padding={24}>
<Text align={'center'} type={'secondary'}>
© 2025 LobeHub. All rights reserved.

View File

@@ -0,0 +1,38 @@
import { Flexbox, type FlexboxProps } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { type ReactNode, memo } from 'react';
interface OnboardingFooterActionsProps extends Omit<FlexboxProps, 'children'> {
left?: ReactNode;
right?: ReactNode;
}
const OnboardingFooterActions = memo<OnboardingFooterActionsProps>(
({ left, right, style, ...rest }) => {
return (
<Flexbox
align={'center'}
horizontal
justify={'space-between'}
style={{
background: cssVar.colorBgContainer,
bottom: 0,
marginTop: 'auto',
paddingTop: 16,
position: 'sticky',
width: '100%',
zIndex: 10,
...style,
}}
{...rest}
>
<div>{left}</div>
<div>{right}</div>
</Flexbox>
);
},
);
OnboardingFooterActions.displayName = 'OnboardingFooterActions';
export default OnboardingFooterActions;

View File

@@ -10,6 +10,7 @@ import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
import LobeMessage from '../components/LobeMessage';
import OnboardingFooterActions from '../components/OnboardingFooterActions';
type DataMode = 'share' | 'privacy';
@@ -48,7 +49,7 @@ const DataModeStep = memo<DataModeStepProps>(({ onBack, onNext }) => {
);
return (
<Flexbox gap={16}>
<Flexbox gap={16} style={{ height: '100%', minHeight: '100%' }}>
<Flexbox>
<LobeMessage sentences={[t('screen4.title'), t('screen4.title2'), t('screen4.title3')]} />
<Text as={'p'}>{t('screen4.description')}</Text>
@@ -113,19 +114,23 @@ const DataModeStep = memo<DataModeStepProps>(({ onBack, onNext }) => {
<Text color={cssVar.colorTextSecondary} fontSize={12} style={{ marginTop: 16 }}>
{t('screen4.footerNote')}
</Text>
<Flexbox horizontal justify={'space-between'} style={{ marginTop: 32 }}>
<Button
icon={Undo2Icon}
onClick={onBack}
style={{ color: cssVar.colorTextDescription }}
type={'text'}
>
{t('back')}
</Button>
<Button onClick={onNext} type={'primary'}>
{t('next')}
</Button>
</Flexbox>
<OnboardingFooterActions
left={
<Button
icon={Undo2Icon}
onClick={onBack}
style={{ color: cssVar.colorTextDescription }}
type={'text'}
>
{t('back')}
</Button>
}
right={
<Button onClick={onNext} type={'primary'}>
{t('next')}
</Button>
}
/>
</Flexbox>
);
});

View File

@@ -1,6 +1,10 @@
'use client';
import { AuthorizationProgress, useWatchBroadcast } from '@lobechat/electron-client-ipc';
import {
AuthorizationPhase,
AuthorizationProgress,
useWatchBroadcast,
} from '@lobechat/electron-client-ipc';
import { Alert, Button, Center, Flexbox, Icon, Input, Text } from '@lobehub/ui';
import { Divider } from 'antd';
import { cssVar } from 'antd-style';
@@ -9,6 +13,7 @@ import { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { isDesktop } from '@/const/version';
import UserInfo from '@/features/User/UserInfo';
import { remoteServerService } from '@/services/electron/remoteServer';
import { useElectronStore } from '@/store/electron';
import { setDesktopAutoOidcFirstOpenHandled } from '@/utils/electron/autoOidc';
@@ -21,6 +26,13 @@ type LoginMethod = 'cloud' | 'selfhost';
// 登录状态类型
type LoginStatus = 'idle' | 'loading' | 'success' | 'error';
const authorizationPhaseI18nKeyMap: Record<AuthorizationPhase, string> = {
browser_opened: 'screen5.auth.phase.browserOpened',
cancelled: 'screen5.actions.cancel',
verifying: 'screen5.auth.phase.verifying',
waiting_for_auth: 'screen5.auth.phase.waitingForAuth',
};
const loginMethodMetas = {
cloud: {
descriptionKey: 'screen5.methods.cloud.description',
@@ -181,6 +193,7 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
setAuthProgress(progress);
if (progress.phase === 'cancelled') {
setCloudLoginStatus('idle');
setSelfhostLoginStatus('idle');
setAuthProgress(null);
}
});
@@ -188,6 +201,7 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
const handleCancelAuth = async () => {
await remoteServerService.cancelAuthorization();
setCloudLoginStatus('idle');
setSelfhostLoginStatus('idle');
setAuthProgress(null);
};
@@ -195,13 +209,19 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
const renderCloudContent = () => {
if (cloudLoginStatus === 'success') {
return (
<Flexbox gap={12} style={{ width: '100%' }}>
<Flexbox gap={16} style={{ width: '100%' }}>
<Alert
description={t('authResult.success.desc')}
style={{ width: '100%' }}
title={t('authResult.success.title')}
type={'success'}
/>
<UserInfo
style={{
background: cssVar.colorFillSecondary,
borderRadius: 8,
}}
/>
<Button
block
disabled={isSigningOut || isConnectingServer}
@@ -239,27 +259,35 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
}
if (cloudLoginStatus === 'loading') {
const phaseText = t(authorizationPhaseI18nKeyMap[authProgress?.phase ?? 'browser_opened'], {
defaultValue: t('screen5.actions.signingIn'),
});
const remainingSeconds = authProgress
? Math.max(0, Math.ceil((authProgress.maxPollTime - authProgress.elapsed) / 1000))
: null;
return (
<Flexbox gap={8} style={{ width: '100%' }}>
<Button block disabled={true} icon={Cloud} loading={true} size={'large'} type={'primary'}>
{authProgress
? t(`screen5.auth.phase.${authProgress.phase}`, {
defaultValue: t('screen5.actions.signingIn'),
})
: t('screen5.actions.signingIn')}
{t('screen5.actions.signingIn')}
</Button>
{authProgress && (
<Flexbox align={'center'} horizontal justify={'space-between'}>
<Text style={{ color: cssVar.colorTextDescription }} type={'secondary'}>
{phaseText}
</Text>
<Flexbox align={'center'} horizontal justify={'space-between'}>
{remainingSeconds !== null ? (
<Text style={{ color: cssVar.colorTextDescription }} type={'secondary'}>
{t('screen5.auth.remaining', {
time: Math.round((authProgress.maxPollTime - authProgress.elapsed) / 1000),
time: remainingSeconds,
})}
</Text>
<Button onClick={handleCancelAuth} size={'small'} type={'text'}>
{t('screen5.actions.cancel')}
</Button>
</Flexbox>
)}
) : (
<div />
)}
<Button onClick={handleCancelAuth} size={'small'} type={'text'}>
{t('screen5.actions.cancel')}
</Button>
</Flexbox>
</Flexbox>
);
}
@@ -283,13 +311,19 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
const renderSelfhostContent = () => {
if (selfhostLoginStatus === 'success') {
return (
<Flexbox gap={12} style={{ width: '100%' }}>
<Flexbox gap={16} style={{ width: '100%' }}>
<Alert
description={t('authResult.success.desc')}
style={{ width: '100%' }}
title={t('authResult.success.title')}
type={'success'}
/>
<UserInfo
style={{
background: cssVar.colorFillSecondary,
borderRadius: 8,
}}
/>
<Button
block
disabled={isSigningOut || isConnectingServer}
@@ -354,8 +388,8 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
};
return (
<Flexbox gap={32}>
<Flexbox>
<Center gap={32} style={{ height: '100%', minHeight: '100%' }}>
<Flexbox align={'flex-start'} justify={'flex-start'} style={{ width: '100%' }}>
<LobeMessage sentences={[t('screen5.title'), t('screen5.title2'), t('screen5.title3')]} />
<Text as={'p'}>{t('screen5.description')}</Text>
</Flexbox>
@@ -401,7 +435,7 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
</Button>
</Flexbox>
)}
</Flexbox>
</Center>
);
});

View File

@@ -18,6 +18,7 @@ import { useTranslation } from 'react-i18next';
import { ensureElectronIpc } from '@/utils/electron/ipc';
import LobeMessage from '../components/LobeMessage';
import OnboardingFooterActions from '../components/OnboardingFooterActions';
type PermissionMeta = {
descriptionKey: string;
@@ -154,7 +155,7 @@ const PermissionsStep = memo<PermissionsStepProps>(({ onBack, onNext }) => {
};
return (
<Flexbox gap={16}>
<Flexbox gap={16} style={{ height: '100%', minHeight: '100%' }}>
<Flexbox>
<LobeMessage sentences={[t('screen3.title'), t('screen3.title2'), t('screen3.title3')]} />
<Text as={'p'}>{t('screen3.description')}</Text>
@@ -207,19 +208,23 @@ const PermissionsStep = memo<PermissionsStepProps>(({ onBack, onNext }) => {
</Block>
))}
</Block>
<Flexbox horizontal justify={'space-between'} style={{ marginTop: 32 }}>
<Button
icon={Undo2Icon}
onClick={onBack}
style={{ color: cssVar.colorTextDescription }}
type={'text'}
>
{t('back')}
</Button>
<Button onClick={onNext} type={'primary'}>
{t('next')}
</Button>
</Flexbox>
<OnboardingFooterActions
left={
<Button
icon={Undo2Icon}
onClick={onBack}
style={{ color: cssVar.colorTextDescription }}
type={'text'}
>
{t('back')}
</Button>
}
right={
<Button onClick={onNext} type={'primary'}>
{t('next')}
</Button>
}
/>
</Flexbox>
);
});

View File

@@ -1,5 +1,6 @@
'use client';
import { APP_WINDOW_MIN_SIZE } from '@lobechat/desktop-bridge';
import { Flexbox, Skeleton } from '@lobehub/ui';
import { Suspense, memo, useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
@@ -50,12 +51,11 @@ const DesktopOnboardingPage = memo(() => {
// 设置窗口大小和可调整性
useEffect(() => {
const fixedSize = { height: 900, width: 1400 };
const minimumSize = { height: 900, width: 1200 };
const applyWindowSettings = async () => {
try {
await electronSystemService.setWindowSize(fixedSize);
await electronSystemService.setWindowResizable({ resizable: false });
await electronSystemService.setWindowMinimumSize(minimumSize);
} catch (error) {
console.error('[DesktopOnboarding] Failed to apply window settings:', error);
}
@@ -64,7 +64,8 @@ const DesktopOnboardingPage = memo(() => {
applyWindowSettings();
return () => {
electronSystemService.setWindowResizable({ resizable: true }).catch((error) => {
// Restore to app-level default minimum size preset
electronSystemService.setWindowMinimumSize(APP_WINDOW_MIN_SIZE).catch((error) => {
console.error('[DesktopOnboarding] Failed to restore window settings:', error);
});
};
@@ -127,9 +128,9 @@ const DesktopOnboardingPage = memo(() => {
// 如果是第4步LoginStep完成 onboarding
setDesktopOnboardingCompleted();
clearDesktopOnboardingStep(); // Clear persisted step since onboarding is complete
// Restore window resizable before hard reload (cleanup won't run due to hard navigation)
// Restore window minimum size before hard reload (cleanup won't run due to hard navigation)
electronSystemService
.setWindowResizable({ resizable: true })
.setWindowMinimumSize(APP_WINDOW_MIN_SIZE)
.catch(console.error)
.finally(() => {
// Use hard reload instead of SPA navigation to ensure the app boots with the new desktop state.
@@ -196,7 +197,7 @@ const DesktopOnboardingPage = memo(() => {
return (
<OnboardingContainer>
<Flexbox gap={24} style={{ maxWidth: 560, width: '100%' }}>
<Flexbox gap={24} style={{ maxWidth: 560, minHeight: '100%', width: '100%' }}>
<Suspense
fallback={
<Flexbox gap={8}>

View File

@@ -1,6 +1,6 @@
import type {
ElectronAppState,
WindowResizableParams,
WindowMinimumSizeParams,
WindowSizeParams,
} from '@lobechat/electron-client-ipc';
@@ -36,14 +36,14 @@ class ElectronSystemService {
return this.ipc.windows.minimizeWindow();
}
async setWindowResizable(params: WindowResizableParams): Promise<void> {
return this.ipc.windows.setWindowResizable(params);
}
async setWindowSize(params: WindowSizeParams): Promise<void> {
return this.ipc.windows.setWindowSize(params);
}
async setWindowMinimumSize(params: WindowMinimumSizeParams): Promise<void> {
return this.ipc.windows.setWindowMinimumSize(params);
}
async openExternalLink(url: string): Promise<void> {
return this.ipc.system.openExternalLink(url);
}