Skip to content

Commit 71e6761

Browse files
authored
Merge pull request AppFlowy-IO#67 from AppFlowy-IO/chat_select_model
chore: fix chat model select and add test
2 parents cbd7ba0 + 01468b2 commit 71e6761

File tree

5 files changed

+319
-43
lines changed

5 files changed

+319
-43
lines changed
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { v4 as uuidv4 } from 'uuid';
2+
import { AuthTestUtils } from '../../support/auth-utils';
3+
import { TestTool } from '../../support/page-utils';
4+
import { PageSelectors, SidebarSelectors, ModelSelectorSelectors } from '../../support/selectors';
5+
6+
describe('Chat Model Selection Persistence Tests', () => {
7+
const APPFLOWY_BASE_URL = Cypress.env('APPFLOWY_BASE_URL');
8+
const APPFLOWY_GOTRUE_BASE_URL = Cypress.env('APPFLOWY_GOTRUE_BASE_URL');
9+
const generateRandomEmail = () => `${uuidv4()}@appflowy.io`;
10+
let testEmail: string;
11+
12+
before(() => {
13+
// Log environment configuration for debugging
14+
cy.task('log', `Test Environment Configuration:
15+
- APPFLOWY_BASE_URL: ${APPFLOWY_BASE_URL}
16+
- APPFLOWY_GOTRUE_BASE_URL: ${APPFLOWY_GOTRUE_BASE_URL}`);
17+
});
18+
19+
beforeEach(() => {
20+
// Generate unique test data for each test
21+
testEmail = generateRandomEmail();
22+
});
23+
24+
describe('Model Selection Persistence', () => {
25+
it('should persist selected model after page reload', () => {
26+
// Handle uncaught exceptions during workspace creation
27+
cy.on('uncaught:exception', (err: Error) => {
28+
if (err.message.includes('No workspace or service found')) {
29+
return false;
30+
}
31+
if (err.message.includes('View not found')) {
32+
return false;
33+
}
34+
if (err.message.includes('WebSocket') || err.message.includes('connection')) {
35+
return false;
36+
}
37+
return true;
38+
});
39+
40+
// Step 1: Login
41+
cy.task('log', '=== Step 1: Login ===');
42+
cy.visit('/login', { failOnStatusCode: false });
43+
cy.wait(2000);
44+
45+
const authUtils = new AuthTestUtils();
46+
authUtils.signInWithTestUrl(testEmail).then(() => {
47+
cy.url().should('include', '/app');
48+
49+
// Wait for the app to fully load
50+
cy.task('log', 'Waiting for app to fully load...');
51+
52+
// Wait for the loading screen to disappear and main app to appear
53+
cy.get('body', { timeout: 30000 }).should('not.contain', 'Welcome!');
54+
55+
// Wait for the sidebar to be visible (indicates app is loaded)
56+
SidebarSelectors.pageHeader().should('be.visible', { timeout: 30000 });
57+
58+
// Wait for at least one page to exist in the sidebar
59+
PageSelectors.names().should('exist', { timeout: 30000 });
60+
61+
// Additional wait for stability
62+
cy.wait(2000);
63+
64+
// Step 2: Create an AI Chat
65+
cy.task('log', '=== Step 2: Creating AI Chat ===');
66+
67+
// Expand the first space to see its pages
68+
TestTool.expandSpace();
69+
cy.wait(1000);
70+
71+
// Find the first page item and hover over it to show actions
72+
PageSelectors.items().first().then($page => {
73+
cy.task('log', 'Hovering over first page to show action buttons...');
74+
75+
// Hover over the page to reveal the action buttons
76+
cy.wrap($page)
77+
.trigger('mouseenter', { force: true })
78+
.trigger('mouseover', { force: true });
79+
80+
cy.wait(1000);
81+
82+
// Click the inline add button (plus icon)
83+
cy.wrap($page).within(() => {
84+
cy.get('[data-testid="inline-add-page"]')
85+
.first()
86+
.should('be.visible')
87+
.click({ force: true });
88+
});
89+
});
90+
91+
// Wait for the dropdown menu to appear
92+
cy.wait(1000);
93+
94+
// Click on the AI Chat option from the dropdown
95+
cy.get('[data-testid="add-ai-chat-button"]')
96+
.should('be.visible')
97+
.click();
98+
99+
cy.task('log', 'Created AI Chat');
100+
101+
// Wait for navigation to the AI chat page
102+
cy.wait(3000);
103+
104+
// Step 3: Open model selector and select a model
105+
cy.task('log', '=== Step 3: Selecting a Model ===');
106+
107+
// Wait for the chat interface to load
108+
cy.wait(2000);
109+
110+
// Click on the model selector button
111+
ModelSelectorSelectors.button()
112+
.should('be.visible', { timeout: 10000 })
113+
.click();
114+
115+
cy.task('log', 'Opened model selector dropdown');
116+
117+
// Wait for the dropdown to appear and models to load
118+
cy.wait(2000);
119+
120+
// Select a specific model (we'll select the first non-Auto model if available)
121+
ModelSelectorSelectors.options()
122+
.then($options => {
123+
// Find a model that's not "Auto"
124+
const nonAutoOptions = $options.filter((i, el) => {
125+
const testId = el.getAttribute('data-testid');
126+
return testId && !testId.includes('model-option-Auto');
127+
});
128+
129+
if (nonAutoOptions.length > 0) {
130+
// Click the first non-Auto model
131+
const selectedModel = nonAutoOptions[0].getAttribute('data-testid')?.replace('model-option-', '');
132+
cy.task('log', `Selecting model: ${selectedModel}`);
133+
cy.wrap(nonAutoOptions[0]).click();
134+
135+
// Store the selected model name for verification
136+
cy.wrap(selectedModel).as('selectedModel');
137+
} else {
138+
// If only Auto is available, select it explicitly
139+
cy.task('log', 'Only Auto model available, selecting it');
140+
ModelSelectorSelectors.optionByName('Auto').click();
141+
cy.wrap('Auto').as('selectedModel');
142+
}
143+
});
144+
145+
// Wait for the selection to be applied
146+
cy.wait(1000);
147+
148+
// Verify the model is selected by checking the button text
149+
cy.get('@selectedModel').then((modelName) => {
150+
cy.task('log', `Verifying model ${modelName} is displayed in button`);
151+
ModelSelectorSelectors.button()
152+
.should('contain.text', modelName);
153+
});
154+
155+
// Step 4: Save the current URL for reload
156+
cy.task('log', '=== Step 4: Saving current URL ===');
157+
cy.url().then(url => {
158+
cy.wrap(url).as('chatUrl');
159+
cy.task('log', `Current chat URL: ${url}`);
160+
});
161+
162+
// Step 5: Reload the page
163+
cy.task('log', '=== Step 5: Reloading page ===');
164+
cy.reload();
165+
166+
// Wait for the page to reload completely
167+
cy.wait(3000);
168+
169+
// Step 6: Verify the model selection persisted
170+
cy.task('log', '=== Step 6: Verifying Model Selection Persisted ===');
171+
172+
// Wait for the model selector button to be visible again
173+
ModelSelectorSelectors.button()
174+
.should('be.visible', { timeout: 10000 });
175+
176+
// Verify the previously selected model is still displayed
177+
cy.get('@selectedModel').then((modelName) => {
178+
cy.task('log', `Checking if model ${modelName} is still selected after reload`);
179+
ModelSelectorSelectors.button()
180+
.should('contain.text', modelName);
181+
cy.task('log', `✓ Model ${modelName} persisted after page reload!`);
182+
});
183+
184+
// Optional: Open the dropdown again to verify the selection visually
185+
cy.task('log', '=== Step 7: Double-checking selection in dropdown ===');
186+
ModelSelectorSelectors.button().click();
187+
cy.wait(1000);
188+
189+
// Verify the selected model has the selected styling
190+
cy.get('@selectedModel').then((modelName) => {
191+
ModelSelectorSelectors.optionByName(modelName as string)
192+
.should('have.class', 'bg-fill-content-select');
193+
cy.task('log', `✓ Model ${modelName} shows as selected in dropdown`);
194+
});
195+
196+
// Close the dropdown
197+
cy.get('body').click(0, 0);
198+
199+
// Final verification
200+
cy.task('log', '=== Test completed successfully! ===');
201+
cy.task('log', '✓✓✓ Model selection persisted after page reload');
202+
});
203+
});
204+
});
205+
});

cypress/support/selectors.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,27 @@ export const SidebarSelectors = {
184184
pageHeader: () => cy.get(byTestId('sidebar-page-header')),
185185
};
186186

187+
/**
188+
* Chat Model Selector-related selectors
189+
* Used for testing AI model selection in chat interface
190+
*/
191+
export const ModelSelectorSelectors = {
192+
// Model selector button
193+
button: () => cy.get(byTestId('model-selector-button')),
194+
195+
// Model search input
196+
searchInput: () => cy.get(byTestId('model-search-input')),
197+
198+
// Get all model options
199+
options: () => cy.get('[data-testid^="model-option-"]'),
200+
201+
// Get specific model option by name
202+
optionByName: (modelName: string) => cy.get(byTestId(`model-option-${modelName}`)),
203+
204+
// Get selected model option (has the selected class)
205+
selectedOption: () => cy.get('[data-testid^="model-option-"]').filter('.bg-fill-content-select'),
206+
};
207+
187208
/**
188209
* Database Grid-related selectors
189210
*/

src/components/chat/chat/main.tsx

Lines changed: 53 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,72 @@
11
// Code: Chat main component
2-
import { ChatContext } from './context';
32
import { ChatInput } from '@/components/chat/components/chat-input';
43
import { ChatMessages } from '@/components/chat/components/chat-messages';
4+
import { ModelSelectorContext } from '@/components/chat/contexts/model-selector-context';
55
import { cn } from '@/components/chat/lib/utils';
6-
import { MessageAnimationProvider } from '@/components/chat/provider/message-animation-provider';
76
import { EditorProvider } from '@/components/chat/provider/editor-provider';
8-
import { MessagesHandlerProvider } from '@/components/chat/provider/messages-handler-provider';
7+
import { MessageAnimationProvider } from '@/components/chat/provider/message-animation-provider';
8+
import { MessagesHandlerProvider, useMessagesHandlerContext } from '@/components/chat/provider/messages-handler-provider';
99
import { ChatMessagesProvider } from '@/components/chat/provider/messages-provider';
1010
import { PromptModalProvider } from '@/components/chat/provider/prompt-modal-provider';
1111
import { ResponseFormatProvider } from '@/components/chat/provider/response-format-provider';
1212
import { SelectionModeProvider } from '@/components/chat/provider/selection-mode-provider';
1313
import { SuggestionsProvider } from '@/components/chat/provider/suggestions-provider';
14-
import { ChatProps } from '@/components/chat/types';
15-
import { AnimatePresence, motion } from 'framer-motion';
1614
import { ViewLoaderProvider } from '@/components/chat/provider/view-loader-provider';
17-
import { ModelSelectorContext } from '@/components/chat/contexts/model-selector-context';
15+
import { ChatProps, User } from '@/components/chat/types';
16+
import { AnimatePresence, motion } from 'framer-motion';
17+
import { ChatContext, useChatContext } from './context';
18+
19+
// Component to bridge ModelSelector with MessagesHandler
20+
function ChatContentWithModelSync({ currentUser, selectionMode }: { currentUser?: User; selectionMode?: boolean }) {
21+
const { selectedModelName, setSelectedModelName } = useMessagesHandlerContext();
22+
const { requestInstance, chatId } = useChatContext();
23+
24+
return (
25+
<ModelSelectorContext.Provider
26+
value={{
27+
selectedModelName,
28+
setSelectedModelName,
29+
requestInstance: {
30+
getModelList: () => requestInstance.getModelList(),
31+
getCurrentModel: async () => {
32+
const settings = await requestInstance.getChatSettings();
33+
34+
return settings.metadata?.ai_model as string | undefined || '';
35+
},
36+
setCurrentModel: async (modelName: string) => {
37+
await requestInstance.updateChatSettings({
38+
metadata: {
39+
ai_model: modelName
40+
}
41+
});
42+
},
43+
},
44+
chatId,
45+
}}
46+
>
47+
<div className={'w-full relative h-full flex flex-col'}>
48+
<ChatMessages currentUser={currentUser} />
49+
<motion.div
50+
layout
51+
className={cn(
52+
'w-full relative flex pb-6 justify-center max-sm:hidden',
53+
)}
54+
>
55+
<AnimatePresence mode='wait'>
56+
{!selectionMode && <ChatInput />}
57+
</AnimatePresence>
58+
</motion.div>
59+
</div>
60+
</ModelSelectorContext.Provider>
61+
);
62+
}
1863

1964
function Main(props: ChatProps) {
2065
const { currentUser, selectionMode } = props;
2166

2267
return (
2368
<ChatContext.Provider value={props}>
24-
<ModelSelectorContext.Provider
25-
value={{
26-
requestInstance: {
27-
getModelList: () => props.requestInstance.getModelList(),
28-
getCurrentModel: async () => {
29-
const settings = await props.requestInstance.getChatSettings();
30-
31-
return settings.metadata?.ai_model as string | undefined || '';
32-
},
33-
setCurrentModel: async (modelName: string) => {
34-
await props.requestInstance.updateChatSettings({
35-
metadata: {
36-
ai_model: modelName
37-
}
38-
});
39-
},
40-
},
41-
chatId: props.chatId,
42-
}}
43-
>
44-
<ChatMessagesProvider>
69+
<ChatMessagesProvider>
4570
<MessageAnimationProvider>
4671
<SuggestionsProvider>
4772
<EditorProvider>
@@ -61,19 +86,7 @@ function Main(props: ChatProps) {
6186
testDatabasePromptConfig={props.testDatabasePromptConfig}
6287
>
6388
<MessagesHandlerProvider>
64-
<div className={'w-full relative h-full flex flex-col'}>
65-
<ChatMessages currentUser={currentUser} />
66-
<motion.div
67-
layout
68-
className={cn(
69-
'w-full relative flex pb-6 justify-center max-sm:hidden',
70-
)}
71-
>
72-
<AnimatePresence mode='wait'>
73-
{!selectionMode && <ChatInput />}
74-
</AnimatePresence>
75-
</motion.div>
76-
</div>
89+
<ChatContentWithModelSync currentUser={currentUser} selectionMode={selectionMode} />
7790
</MessagesHandlerProvider>
7891
</PromptModalProvider>
7992
</ResponseFormatProvider>
@@ -83,7 +96,6 @@ function Main(props: ChatProps) {
8396
</SuggestionsProvider>
8497
</MessageAnimationProvider>
8598
</ChatMessagesProvider>
86-
</ModelSelectorContext.Provider>
8799
</ChatContext.Provider>
88100
);
89101
}

src/components/chat/components/chat-input/model-selector/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ export function ModelSelector({ className, disabled }: ModelSelectorProps) {
208208
className={cn('h-7 gap-1 px-2 text-xs font-normal text-text-secondary', className)}
209209
onMouseDown={(e) => e.preventDefault()}
210210
disabled={disabled}
211+
data-testid="model-selector-button"
211212
title={hasContext ? 'Select AI Model' : 'Model selector (offline mode)'}
212213
>
213214
{AISparksIcon ? <AISparksIcon className='h-5 w-5 text-icon-secondary' /> : <span className='text-[10px]'>🤖</span>}
@@ -229,6 +230,7 @@ export function ModelSelector({ className, disabled }: ModelSelectorProps) {
229230
value={searchQuery}
230231
onChange={(e) => setSearchQuery(e.target.value)}
231232
className='w-full bg-transparent px-2 py-1 text-sm outline-none placeholder:text-text-placeholder'
233+
data-testid="model-search-input"
232234
onKeyDown={(e) => {
233235
if (e.key === 'Escape') {
234236
setOpen(false);
@@ -238,7 +240,7 @@ export function ModelSelector({ className, disabled }: ModelSelectorProps) {
238240
</div>
239241

240242
{/* Models List */}
241-
<div className='max-h-[380px] overflow-y-auto py-1'>
243+
<div className='appflowy-scrollbar max-h-[380px] overflow-y-auto py-1'>
242244
{loading ? (
243245
<div className='px-3 py-8 text-center text-sm text-text-secondary'>Loading models...</div>
244246
) : filteredModels.length === 0 ? (
@@ -260,6 +262,7 @@ export function ModelSelector({ className, disabled }: ModelSelectorProps) {
260262
'focus:bg-fill-content-hover focus:outline-none',
261263
isSelected && 'bg-fill-content-select'
262264
)}
265+
data-testid={`model-option-${model.name}`}
263266
>
264267
<div className='min-w-0 flex-1'>
265268
<div className='flex items-center gap-2'>

0 commit comments

Comments
 (0)