From 9fc628941130a37ee6786541379f50989bcbf04c Mon Sep 17 00:00:00 2001 From: Bryce Parsons Date: Fri, 1 Aug 2025 17:41:18 -0700 Subject: [PATCH 1/6] v0 @ Menu Created v.0 of @ menu. --- apps/web/client/package.json | 3 +- apps/web/client/src/app/api/chat/route.ts | 23 +- .../right-panel/chat-tab/at-menu/index.tsx | 263 ++++++++++++++ .../chat-tab/at-menu/menu-item.tsx | 70 ++++ .../right-panel/chat-tab/at-menu/test.tsx | 133 +++++++ .../right-panel/chat-tab/chat-input/index.tsx | 128 ++++++- .../right-panel/chat-tab/index.tsx | 64 +++- apps/web/client/src/app/test-at-menu/page.tsx | 332 ++++++++++++++++++ .../editor/chat/at-menu/data-providers.ts | 228 ++++++++++++ .../store/editor/chat/at-menu/fuzzy-search.ts | 64 ++++ .../store/editor/chat/at-menu/types.ts | 34 ++ .../components/store/editor/chat/context.ts | 8 +- .../store/editor/font/font-config-manager.ts | 25 +- .../src/components/store/editor/font/index.ts | 1 + .../store/editor/font/tailwind-config.ts | 8 +- bun.lock | 1 + packages/ai/src/chat/providers.ts | 9 +- 17 files changed, 1379 insertions(+), 15 deletions(-) create mode 100644 apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/at-menu/index.tsx create mode 100644 apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/at-menu/menu-item.tsx create mode 100644 apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/at-menu/test.tsx create mode 100644 apps/web/client/src/app/test-at-menu/page.tsx create mode 100644 apps/web/client/src/components/store/editor/chat/at-menu/data-providers.ts create mode 100644 apps/web/client/src/components/store/editor/chat/at-menu/fuzzy-search.ts create mode 100644 apps/web/client/src/components/store/editor/chat/at-menu/types.ts diff --git a/apps/web/client/package.json b/apps/web/client/package.json index 69071932e2..fa7bd77c9b 100644 --- a/apps/web/client/package.json +++ b/apps/web/client/package.json @@ -99,7 +99,8 @@ "use-resize-observer": "^9.1.0", "uuid": "^11.1.0", "webfontloader": "^1.6.28", - "zod": "^3.24.3" + "zod": "^3.24.3", + "@mendable/firecrawl-js": "^1.29.1" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", diff --git a/apps/web/client/src/app/api/chat/route.ts b/apps/web/client/src/app/api/chat/route.ts index 9478f617f0..8c5d2ae7a6 100644 --- a/apps/web/client/src/app/api/chat/route.ts +++ b/apps/web/client/src/app/api/chat/route.ts @@ -10,8 +10,8 @@ import { type NextRequest } from 'next/server'; const isProd = env.NODE_ENV === 'production'; const MainModelConfig: InitialModelPayload = isProd ? { - provider: LLMProvider.OPENROUTER, - model: OPENROUTER_MODELS.CLAUDE_4_SONNET, + provider: LLMProvider.ANTHROPIC, + model: CLAUDE_MODELS.SONNET_4, } : { provider: LLMProvider.ANTHROPIC, model: CLAUDE_MODELS.SONNET_4, @@ -102,7 +102,24 @@ export const getSupabaseUser = async (request: NextRequest) => { export const streamResponse = async (req: NextRequest) => { const { messages, maxSteps, chatType } = await req.json(); - const { model, providerOptions, headers } = await initModel(MainModelConfig); + + let model, providerOptions, headers; + try { + const modelConfig = await initModel(MainModelConfig); + model = modelConfig.model; + providerOptions = modelConfig.providerOptions; + headers = modelConfig.headers; + } catch (error) { + console.error('Error initializing model:', error); + return new Response(JSON.stringify({ + error: 'Failed to initialize AI model. Please check your API key configuration.', + code: 500, + details: error instanceof Error ? error.message : String(error) + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } // Updating the usage record and rate limit is done here to avoid // abuse in the case where a single user sends many concurrent requests. diff --git a/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/at-menu/index.tsx b/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/at-menu/index.tsx new file mode 100644 index 0000000000..d37de394db --- /dev/null +++ b/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/at-menu/index.tsx @@ -0,0 +1,263 @@ +import { useRef, useEffect, useState } from 'react'; +import { motion } from 'motion/react'; +import { Icons } from '@onlook/ui/icons'; +import { cn } from '@onlook/ui/utils'; +import { MenuItem } from './menu-item'; +import { AtMenuDataProviders } from '@/components/store/editor/chat/at-menu/data-providers'; +import { FuzzySearch } from '@/components/store/editor/chat/at-menu/fuzzy-search'; +import type { AtMenuItem, AtMenuState } from '@/components/store/editor/chat/at-menu/types'; +import { createPortal } from 'react-dom'; + +interface AtMenuProps { + state: AtMenuState; + onSelectItem: (item: AtMenuItem) => void; + onClose: () => void; + onStateChange: (state: Partial) => void; + editorEngine: any; +} + +export const AtMenu = ({ state, onSelectItem, onClose, onStateChange, editorEngine }: AtMenuProps) => { + const menuRef = useRef(null); + const [dataProviders] = useState(() => new AtMenuDataProviders(editorEngine)); + const [allItems, setAllItems] = useState([]); + const [filteredItems, setFilteredItems] = useState>({ + recents: [], + folders: [], + code: [], + leftPanel: [] + }); + + // Get all items from data providers + useEffect(() => { + const items = dataProviders.getAllItems(); + console.log('AtMenu: Loaded items:', items.length); + setAllItems(items); + }, [dataProviders]); + + // Filter items based on search query + useEffect(() => { + if (state.searchQuery) { + const results = FuzzySearch.getGroupedResults(state.searchQuery, allItems); + setFilteredItems(results); + } else { + const results = { + recents: dataProviders.getRecents(), + folders: dataProviders.getFoldersAndFiles(), + code: dataProviders.getCodeFiles(), + leftPanel: dataProviders.getLeftPanelItems() + }; + console.log('AtMenu: Filtered items:', results); + setFilteredItems(results); + } + }, [state.searchQuery, allItems, dataProviders]); + + // Handle keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!state.isOpen) return; + + const allItems = [ + ...(filteredItems.recents || []), + ...(filteredItems.folders || []), + ...(filteredItems.code || []), + ...(filteredItems.leftPanel || []) + ]; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + const nextIndex = state.selectedIndex < allItems.length - 1 ? state.selectedIndex + 1 : 0; + onStateChange({ selectedIndex: nextIndex }); + break; + case 'ArrowUp': + e.preventDefault(); + const prevIndex = state.selectedIndex > 0 ? state.selectedIndex - 1 : allItems.length - 1; + onStateChange({ selectedIndex: prevIndex }); + break; + case 'Enter': + e.preventDefault(); + if (state.selectedIndex >= 0 && state.selectedIndex < allItems.length) { + const selectedItem = allItems[state.selectedIndex]; + if (selectedItem) { + onSelectItem(selectedItem); + } + } + break; + case 'Escape': + e.preventDefault(); + onClose(); + break; + } + }; + + if (state.isOpen) { + document.addEventListener('keydown', handleKeyDown); + } + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [state.isOpen, state.selectedIndex, filteredItems, onSelectItem, onClose]); + + // Handle click outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + if (state.isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [state.isOpen, onClose]); + + console.log('AtMenu render:', { + isOpen: state.isOpen, + position: state.position, + allItemsCount: allItems.length, + filteredItemsCount: Object.values(filteredItems).flat().length + }); + + if (!state.isOpen) return null; + + const allItemsFlat = [ + ...(filteredItems.recents || []), + ...(filteredItems.folders || []), + ...(filteredItems.code || []), + ...(filteredItems.leftPanel || []) + ]; + + const renderCategory = (title: string, items: AtMenuItem[], category: string) => { + if (items.length === 0) return null; + + return ( +
+

+ {title} +

+
+ {items.map((item, index) => { + const globalIndex = getGlobalIndex(category, index); + const isSelected = globalIndex === state.selectedIndex; + + return ( + onSelectItem(item)} + onMouseEnter={() => { + onStateChange({ selectedIndex: globalIndex }); + }} + showChevron={item.hasChildren} + /> + ); + })} +
+ {category !== 'leftPanel' && ( +
+ )} +
+ ); + }; + + const getGlobalIndex = (category: string, localIndex: number): number => { + const categories = ['recents', 'folders', 'code', 'leftPanel']; + const categoryIndex = categories.indexOf(category); + let globalIndex = localIndex; + + for (let i = 0; i < categoryIndex; i++) { + const category = categories[i]; + if (category) { + globalIndex += (filteredItems[category] || []).length; + } + } + + return globalIndex; + }; + + // Calculate menu position with better viewport handling + const calculateMenuPosition = () => { + const menuWidth = 300; + const menuHeight = Math.min(400, allItemsFlat.length * 40 + 100); // Estimate height + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // state.position.top is the textarea's top position + // We want the menu's BOTTOM to be above the textarea + let top = state.position.top - menuHeight - 10; // 10px gap between menu bottom and textarea top + let left = state.position.left; + + // If the menu would go off the top of the screen, position it below the textarea + if (top < 10) { + top = state.position.top + 25; // 25px below textarea top + } + + // If the menu would go off the bottom of the screen, adjust to fit + if (top + menuHeight > viewportHeight - 10) { + top = Math.max(10, viewportHeight - menuHeight - 10); + } + + // Ensure menu doesn't go off the left of the screen + if (left < 10) { + left = 10; + } + + // Ensure menu doesn't go off the right of the screen + if (left + menuWidth > viewportWidth - 10) { + left = viewportWidth - menuWidth - 10; + } + + return { top, left }; + }; + + const menuPosition = calculateMenuPosition(); + + const menuContent = ( + +
+ {renderCategory('Recents', filteredItems.recents || [], 'recents')} + {renderCategory('Folders & Files', filteredItems.folders || [], 'folders')} + {renderCategory('Code', filteredItems.code || [], 'code')} + {renderCategory('Left Panel', filteredItems.leftPanel || [], 'leftPanel')} + + {allItemsFlat.length === 0 && ( +
+
+ Loading items... +
+
+ )} + + {state.searchQuery && allItemsFlat.length === 0 && ( +
+
+ No files or folders match "{state.searchQuery}" +
+
+ )} +
+
+ ); + + // Render the menu at the document body level to avoid overflow clipping + return createPortal(menuContent, document.body); +}; \ No newline at end of file diff --git a/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/at-menu/menu-item.tsx b/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/at-menu/menu-item.tsx new file mode 100644 index 0000000000..e27060abe5 --- /dev/null +++ b/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/at-menu/menu-item.tsx @@ -0,0 +1,70 @@ +import { Icons } from '@onlook/ui/icons'; +import { cn } from '@onlook/ui/utils'; +import type { AtMenuItem } from '@/components/store/editor/chat/at-menu/types'; + +interface MenuItemProps { + item: AtMenuItem; + isSelected: boolean; + onClick: () => void; + onMouseEnter: () => void; + showChevron?: boolean; +} + +export const MenuItem = ({ item, isSelected, onClick, onMouseEnter, showChevron }: MenuItemProps) => { + const getIcon = () => { + switch (item.icon) { + case 'code': + return ; + case 'file': + return ; + case 'directory': + case 'folder': + return ; + case 'layers': + return ; + case 'brand': + return ; + case 'image': + return ; + case 'viewGrid': + return ; + case 'component': + return ; + default: + return ; + } + }; + + return ( + + ); +}; \ No newline at end of file diff --git a/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/at-menu/test.tsx b/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/at-menu/test.tsx new file mode 100644 index 0000000000..f5aafbf076 --- /dev/null +++ b/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/at-menu/test.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react'; +import { AtMenu } from './index'; +import type { AtMenuItem, AtMenuState } from '@/components/store/editor/chat/at-menu/types'; + +// Mock editor engine for testing +const mockEditorEngine = { + ide: { + openedFiles: [ + { filename: 'page.tsx', path: '/src/app' }, + { filename: 'layout.tsx', path: '/src/app' }, + { filename: 'globals.css', path: '/src/app' } + ], + files: [ + { + name: 'src', + type: 'directory', + children: [ + { + name: 'app', + type: 'directory', + children: [ + { name: 'page.tsx', type: 'file' }, + { name: 'layout.tsx', type: 'file' } + ] + } + ] + } + ] + } +}; + +export const AtMenuTest = () => { + const [inputValue, setInputValue] = useState(''); + const [atMenuState, setAtMenuState] = useState({ + isOpen: false, + position: { top: 0, left: 0 }, + selectedIndex: 0, + searchQuery: '', + activeMention: false, + previewText: '' + }); + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setInputValue(value); + + // Check if user typed "@" + if (value.endsWith('@')) { + setAtMenuState(prev => ({ + ...prev, + isOpen: true, + position: { top: 100, left: 100 }, + selectedIndex: 0, + searchQuery: '', + activeMention: true + })); + } else if (atMenuState.activeMention && value.includes('@')) { + const lastAtIndex = value.lastIndexOf('@'); + const textAfterAt = value.substring(lastAtIndex + 1); + setAtMenuState(prev => ({ + ...prev, + searchQuery: textAfterAt + })); + } else { + setAtMenuState(prev => ({ + ...prev, + isOpen: false, + activeMention: false, + searchQuery: '', + previewText: '' + })); + } + }; + + const handleSelectItem = (item: AtMenuItem) => { + const lastAtIndex = inputValue.lastIndexOf('@'); + const textBeforeAt = inputValue.substring(0, lastAtIndex); + const newValue = textBeforeAt + `@${item.name} `; + setInputValue(newValue); + + setAtMenuState(prev => ({ + ...prev, + isOpen: false, + activeMention: false, + searchQuery: '', + previewText: '' + })); + }; + + const handleClose = () => { + setAtMenuState(prev => ({ + ...prev, + isOpen: false, + activeMention: false, + searchQuery: '', + previewText: '' + })); + }; + + const handleStateChange = (newState: Partial) => { + setAtMenuState(prev => ({ + ...prev, + ...newState + })); + }; + + return ( +
+

@ Menu Test

+