Skip to content

Commit c9d9dff

Browse files
authored
♻️ refactor(ModelSwitchPanel): migrate from Popover to DropdownMenu with virtual scrolling (#11663)
* ♻️ refactor(ModelSwitchPanel): migrate from Popover to DropdownMenu with virtual scrolling - Replace Popover with DropdownMenu atom components from @lobehub/ui - Add react-virtuoso for proper virtual scrolling implementation - Auto-close submenu when scrolling to prevent position offset issues - Rename misleading "Virtual*" naming to "List*" for clarity LOBE-3844 * 🔨 chore: clean up unnecessary comments in ModelSwitchPanel * 🔨 chore(router): remove unused loader property from route configuration Signed-off-by: Innei <tukon479@gmail.com> --------- Signed-off-by: Innei <tukon479@gmail.com>
1 parent cf5320e commit c9d9dff

File tree

16 files changed

+404
-200
lines changed

16 files changed

+404
-200
lines changed

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ This repository adopts a monorepo structure.
3232

3333
see @.cursor/rules/typescript.mdc
3434

35+
### Code Comments
36+
37+
- **Avoid meaningless comments**: Do not write comments that merely restate what the code does. Comments should explain _why_ something is done, not _what_ is being done. The code itself should be self-explanatory.
38+
3539
### Testing
3640

3741
- **Required Rule**: read `.cursor/rules/testing-guide/testing-guide.mdc` before writing tests

apps/desktop/src/main/core/browser/Browser.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-c
33
import {
44
BrowserWindow,
55
BrowserWindowConstructorOptions,
6+
Menu,
67
session as electronSession,
78
ipcMain,
89
screen,
@@ -11,7 +12,7 @@ import console from 'node:console';
1112
import { join } from 'node:path';
1213

1314
import { preloadDir, resourcesDir } from '@/const/dir';
14-
import { isMac } from '@/const/env';
15+
import { isDev, isMac } from '@/const/env';
1516
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
1617
import RemoteServerConfigCtr from '@/controllers/RemoteServerConfigCtr';
1718
import { backendProxyProtocolManager } from '@/core/infrastructure/BackendProxyProtocolManager';
@@ -191,6 +192,7 @@ export default class Browser {
191192
this.setupCloseListener(browserWindow);
192193
this.setupFocusListener(browserWindow);
193194
this.setupWillPreventUnloadListener(browserWindow);
195+
this.setupDevContextMenu(browserWindow);
194196
}
195197

196198
private setupWillPreventUnloadListener(browserWindow: BrowserWindow): void {
@@ -236,6 +238,43 @@ export default class Browser {
236238
});
237239
}
238240

241+
/**
242+
* Setup context menu with "Inspect Element" option in development mode
243+
*/
244+
private setupDevContextMenu(browserWindow: BrowserWindow): void {
245+
if (!isDev) return;
246+
247+
logger.debug(`[${this.identifier}] Setting up dev context menu.`);
248+
249+
browserWindow.webContents.on('context-menu', (_event, params) => {
250+
const { x, y } = params;
251+
252+
const menu = Menu.buildFromTemplate([
253+
{
254+
click: () => {
255+
browserWindow.webContents.inspectElement(x, y);
256+
},
257+
label: 'Inspect Element',
258+
},
259+
{ type: 'separator' },
260+
{
261+
click: () => {
262+
browserWindow.webContents.openDevTools();
263+
},
264+
label: 'Open DevTools',
265+
},
266+
{
267+
click: () => {
268+
browserWindow.webContents.reload();
269+
},
270+
label: 'Reload',
271+
},
272+
]);
273+
274+
menu.popup({ window: browserWindow });
275+
});
276+
}
277+
239278
// ==================== Window Actions ====================
240279

241280
show(): void {

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,12 @@
3535
"prebuild": "tsx scripts/prebuild.mts && npm run lint",
3636
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build --webpack",
3737
"postbuild": "npm run build-sitemap && npm run build-migrate-db",
38+
"build-migrate-db": "bun run db:migrate",
39+
"build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts",
3840
"build:analyze": "NODE_OPTIONS=--max-old-space-size=81920 ANALYZE=true next build --webpack",
3941
"build:docker": "npm run prebuild && NODE_OPTIONS=--max-old-space-size=8192 DOCKER=true next build --webpack && npm run build-sitemap",
4042
"build:electron": "cross-env NODE_OPTIONS=--max-old-space-size=8192 NEXT_PUBLIC_IS_DESKTOP_APP=1 tsx scripts/electronWorkflow/buildNextApp.mts",
4143
"build:vercel": "npm run prebuild && cross-env NODE_OPTIONS=--max-old-space-size=6144 next build --webpack && npm run postbuild",
42-
"build-migrate-db": "bun run db:migrate",
43-
"build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts",
4444
"clean:node_modules": "bash -lc 'set -e; echo \"Removing all node_modules...\"; rm -rf node_modules; pnpm -r exec rm -rf node_modules; rm -rf apps/desktop/node_modules; echo \"All node_modules removed.\"'",
4545
"db:generate": "drizzle-kit generate && npm run workflow:dbml",
4646
"db:migrate": "MIGRATION_DB=1 tsx ./scripts/migrateServerDB/index.ts",
@@ -87,11 +87,11 @@
8787
"start": "next start -p 3210",
8888
"stylelint": "stylelint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
8989
"test": "npm run test-app && npm run test-server",
90+
"test-app": "vitest run",
91+
"test-app:coverage": "vitest --coverage --silent='passed-only'",
9092
"test:e2e": "pnpm --filter @lobechat/e2e-tests test",
9193
"test:e2e:smoke": "pnpm --filter @lobechat/e2e-tests test:smoke",
9294
"test:update": "vitest -u",
93-
"test-app": "vitest run",
94-
"test-app:coverage": "vitest --coverage --silent='passed-only'",
9595
"tunnel:cloudflare": "cloudflared tunnel --url http://localhost:3010",
9696
"tunnel:ngrok": "ngrok http http://localhost:3011",
9797
"type-check": "tsgo --noEmit",
@@ -207,7 +207,7 @@
207207
"@lobehub/icons": "^4.0.2",
208208
"@lobehub/market-sdk": "0.29.1",
209209
"@lobehub/tts": "^4.0.2",
210-
"@lobehub/ui": "^4.22.0",
210+
"@lobehub/ui": "^4.24.0",
211211
"@modelcontextprotocol/sdk": "^1.25.1",
212212
"@neondatabase/serverless": "^1.0.2",
213213
"@next/third-parties": "^16.1.1",

src/app/[variants]/(main)/settings/provider/features/ProviderConfig/Checker.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { CheckCircleFilled } from '@ant-design/icons';
44
import { type ChatMessageError, TraceNameMap } from '@lobechat/types';
55
import { ModelIcon } from '@lobehub/icons';
6-
import { Alert, Button, Flexbox, Highlighter, Icon, Select } from '@lobehub/ui';
6+
import { Alert, Button, Flexbox, Highlighter, Icon, LobeSelect as Select } from '@lobehub/ui';
77
import { cssVar } from 'antd-style';
88
import { Loader2Icon } from 'lucide-react';
99
import { type ReactNode, memo, useEffect, useState } from 'react';
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import type { AiModelForSelect } from 'model-bank';
2+
3+
import type { EnabledProviderWithModels } from '@/types/aiProvider';
4+
5+
/**
6+
* Mock data for testing ModelSwitchPanel
7+
*
8+
* This data includes:
9+
* - Multiple providers (OpenAI, Azure, Ollama)
10+
* - Same model provided by multiple providers (gpt-4o -> model-item-multiple)
11+
* - Single provider model (llama3 -> model-item-single)
12+
*/
13+
export const mockEnabledChatModels: EnabledProviderWithModels[] = [
14+
{
15+
children: [
16+
{
17+
abilities: {
18+
functionCall: true,
19+
reasoning: false,
20+
vision: true,
21+
},
22+
contextWindowTokens: 128_000,
23+
displayName: 'GPT-4o',
24+
id: 'gpt-4o',
25+
maxOutput: 16_384,
26+
releasedAt: '2024-05-13',
27+
type: 'chat',
28+
} as AiModelForSelect,
29+
{
30+
abilities: {
31+
functionCall: true,
32+
reasoning: false,
33+
vision: true,
34+
},
35+
contextWindowTokens: 128_000,
36+
displayName: 'GPT-4o Mini',
37+
id: 'gpt-4o-mini',
38+
maxOutput: 16_384,
39+
releasedAt: '2024-07-18',
40+
type: 'chat',
41+
} as AiModelForSelect,
42+
{
43+
abilities: {
44+
functionCall: true,
45+
reasoning: true,
46+
vision: false,
47+
},
48+
contextWindowTokens: 200_000,
49+
displayName: 'o1',
50+
id: 'o1',
51+
maxOutput: 100_000,
52+
releasedAt: '2024-12-17',
53+
type: 'chat',
54+
} as AiModelForSelect,
55+
],
56+
id: 'openai',
57+
logo: 'https://registry.npmmirror.com/@lobehub/icons-static-png/1.45.0/files/dark/openai.png',
58+
name: 'OpenAI',
59+
source: 'builtin',
60+
},
61+
{
62+
children: [
63+
{
64+
// Same displayName as OpenAI's gpt-4o -> will create model-item-multiple
65+
abilities: {
66+
functionCall: true,
67+
reasoning: false,
68+
vision: true,
69+
},
70+
contextWindowTokens: 128_000,
71+
displayName: 'GPT-4o',
72+
id: 'gpt-4o',
73+
maxOutput: 16_384,
74+
type: 'chat',
75+
} as AiModelForSelect,
76+
{
77+
// Same displayName as OpenAI's gpt-4o-mini -> will create model-item-multiple
78+
abilities: {
79+
functionCall: true,
80+
reasoning: false,
81+
vision: true,
82+
},
83+
contextWindowTokens: 128_000,
84+
displayName: 'GPT-4o Mini',
85+
id: 'gpt-4o-mini',
86+
maxOutput: 16_384,
87+
type: 'chat',
88+
} as AiModelForSelect,
89+
],
90+
id: 'azure',
91+
logo: 'https://registry.npmmirror.com/@lobehub/icons-static-png/1.45.0/files/dark/azure.png',
92+
name: 'Azure OpenAI',
93+
source: 'builtin',
94+
},
95+
{
96+
children: [
97+
{
98+
// Unique model -> will create model-item-single
99+
abilities: {
100+
functionCall: true,
101+
reasoning: false,
102+
vision: false,
103+
},
104+
contextWindowTokens: 128_000,
105+
displayName: 'Llama 3.3 70B',
106+
id: 'llama3.3:70b',
107+
maxOutput: 8192,
108+
type: 'chat',
109+
} as AiModelForSelect,
110+
{
111+
abilities: {
112+
functionCall: false,
113+
reasoning: false,
114+
vision: true,
115+
},
116+
contextWindowTokens: 128_000,
117+
displayName: 'Llava',
118+
id: 'llava:latest',
119+
maxOutput: 4096,
120+
type: 'chat',
121+
} as AiModelForSelect,
122+
],
123+
id: 'ollama',
124+
logo: 'https://registry.npmmirror.com/@lobehub/icons-static-png/1.45.0/files/dark/ollama.png',
125+
name: 'Ollama',
126+
source: 'builtin',
127+
},
128+
{
129+
children: [
130+
{
131+
// Same as OpenAI's o1 -> will create model-item-multiple
132+
abilities: {
133+
functionCall: true,
134+
reasoning: true,
135+
vision: false,
136+
},
137+
contextWindowTokens: 200_000,
138+
displayName: 'o1',
139+
id: 'o1',
140+
maxOutput: 100_000,
141+
type: 'chat',
142+
} as AiModelForSelect,
143+
],
144+
id: 'openrouter',
145+
logo: 'https://registry.npmmirror.com/@lobehub/icons-static-png/1.45.0/files/dark/openrouter.png',
146+
name: 'OpenRouter',
147+
source: 'builtin',
148+
},
149+
];
150+
151+
/**
152+
* Expected result when groupMode = 'byModel':
153+
*
154+
* - GPT-4o (model-item-multiple) -> OpenAI, Azure
155+
* - GPT-4o Mini (model-item-multiple) -> OpenAI, Azure
156+
* - Llama 3.3 70B (model-item-single) -> Ollama
157+
* - Llava (model-item-single) -> Ollama
158+
* - o1 (model-item-multiple) -> OpenAI, OpenRouter
159+
*/

src/features/ModelSwitchPanel/components/List/VirtualItemRenderer.tsx renamed to src/features/ModelSwitchPanel/components/List/ListItemRenderer.tsx

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,22 @@ import urlJoin from 'url-join';
99
import { ModelItemRender, ProviderItemRender } from '@/components/ModelSelect';
1010

1111
import { styles } from '../../styles';
12-
import type { VirtualItem } from '../../types';
12+
import type { ListItem } from '../../types';
1313
import { menuKey } from '../../utils';
1414
import { MultipleProvidersModelItem } from './MultipleProvidersModelItem';
1515
import { SingleProviderModelItem } from './SingleProviderModelItem';
1616

17-
interface VirtualItemRendererProps {
17+
interface ListItemRendererProps {
1818
activeKey: string;
19-
item: VirtualItem;
19+
isScrolling: boolean;
20+
item: ListItem;
2021
newLabel: string;
2122
onClose: () => void;
2223
onModelChange: (modelId: string, providerId: string) => Promise<void>;
2324
}
2425

25-
export const VirtualItemRenderer = memo<VirtualItemRendererProps>(
26-
({ activeKey, item, newLabel, onModelChange, onClose }) => {
26+
export const ListItemRenderer = memo<ListItemRendererProps>(
27+
({ activeKey, isScrolling, item, newLabel, onModelChange, onClose }) => {
2728
const { t } = useTranslation('components');
2829
const navigate = useNavigate();
2930

@@ -145,27 +146,16 @@ export const VirtualItemRenderer = memo<VirtualItemRendererProps>(
145146
}
146147

147148
case 'model-item-multiple': {
148-
// Check if any provider of this model is active
149-
const activeProvider = item.data.providers.find(
150-
(p) => menuKey(p.id, item.data.model.id) === activeKey,
151-
);
152-
const isActive = !!activeProvider;
153-
154149
return (
155-
<Block
156-
className={styles.menuItem}
157-
clickable
150+
<MultipleProvidersModelItem
151+
activeKey={activeKey}
152+
data={item.data}
153+
isScrolling={isScrolling}
158154
key={item.data.displayName}
159-
variant={isActive ? 'filled' : 'borderless'}
160-
>
161-
<MultipleProvidersModelItem
162-
activeKey={activeKey}
163-
data={item.data}
164-
newLabel={newLabel}
165-
onClose={onClose}
166-
onModelChange={onModelChange}
167-
/>
168-
</Block>
155+
newLabel={newLabel}
156+
onClose={onClose}
157+
onModelChange={onModelChange}
158+
/>
169159
);
170160
}
171161

@@ -176,4 +166,4 @@ export const VirtualItemRenderer = memo<VirtualItemRendererProps>(
176166
},
177167
);
178168

179-
VirtualItemRenderer.displayName = 'VirtualItemRenderer';
169+
ListItemRenderer.displayName = 'ListItemRenderer';

0 commit comments

Comments
 (0)