diff --git a/.changeset/fn-1467-pr-feedback-fixes.md b/.changeset/fn-1467-pr-feedback-fixes.md new file mode 100644 index 0000000000..18a8d10399 --- /dev/null +++ b/.changeset/fn-1467-pr-feedback-fixes.md @@ -0,0 +1,3 @@ +"@runfusion/fusion": patch + +Fix project selector review regressions around optional selection handlers and bookmarked search matches, and tighten retry/backoff timeout and rate-limit handling. diff --git a/packages/dashboard/app/components/AgentsView.css b/packages/dashboard/app/components/AgentsView.css index 7780644957..9c4e6a3431 100644 --- a/packages/dashboard/app/components/AgentsView.css +++ b/packages/dashboard/app/components/AgentsView.css @@ -52,6 +52,7 @@ position: relative; display: flex; flex-direction: column; + flex: 1; height: 100%; overflow: hidden; } diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index 9559207c93..ef8380d15f 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -2,6 +2,8 @@ .chat-view { display: flex; + flex: 1; + width: 100%; height: 100%; overflow: hidden; } diff --git a/packages/dashboard/app/components/Header.tsx b/packages/dashboard/app/components/Header.tsx index 034afa89a4..11ac2f5fd1 100644 --- a/packages/dashboard/app/components/Header.tsx +++ b/packages/dashboard/app/components/Header.tsx @@ -2,8 +2,9 @@ import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent a import { useTranslation } from "react-i18next"; import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Server, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock } from "lucide-react"; import "./Header.css"; -// Header renders an inline ProjectSelector dropdown using project-selector-* classes. +// ProjectSelector styles used by the imported standalone component. import "./ProjectSelector.css"; +import { ProjectSelector as StandaloneProjectSelector } from "./ProjectSelector"; import type { ProjectInfo } from "../api"; import type { NodeConfig, ProjectStatus } from "@fusion/core"; import { fetchScripts } from "../api"; @@ -30,125 +31,8 @@ const PROJECT_STATUS_CONFIG: Record = { initializing: { color: "var(--info)" }, }; -/** - * ProjectSelector - A component for project navigation. - * Shows project dropdown for switching projects and navigating to project management. - */ -function ProjectSelector({ - projects, - currentProject, - onViewAll, - onSelectProject, -}: { - projects: ProjectInfo[]; - currentProject: ProjectInfo | null; - onViewAll: () => void; - onSelectProject?: (project: ProjectInfo) => void; -}) { - const { t } = useTranslation("app"); - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); - - // Close dropdown on outside click - useEffect(() => { - if (!isOpen) return; - const handleClickOutside = (e: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { - setIsOpen(false); - } - }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [isOpen]); - - // Close on Escape - useEffect(() => { - if (!isOpen) return; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - setIsOpen(false); - } - }; - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [isOpen]); - - const handleSelectProject = useCallback( - (project: ProjectInfo) => { - onSelectProject?.(project); - setIsOpen(false); - }, - [onSelectProject] - ); - - return ( -
- {projects.length > 0 && ( - <> - - {isOpen && ( -
- {projects.map((project) => { - const isCurrent = currentProject?.id === project.id; - const statusColor = PROJECT_STATUS_CONFIG[project.status]?.color; - return ( - - ); - })} -
- -
- )} - - )} -
- ); -} +// Inline ProjectSelector removed — now imports StandaloneProjectSelector from ./ProjectSelector +// which has scroll fix, autocomplete, and bookmarking features. // GitHub logo icon (Octocat mark) - uses currentColor for theme compatibility function GitHubLogo({ size = 16 }: { size?: number }) { @@ -952,12 +836,12 @@ export function Header({ )} {/* Project Selector - Back button when project selected, dropdown when 2+ projects (tablet + desktop) */} - {!isMobile && projects.length >= 1 && onViewAllProjects && ( - = 1 && onViewAllProjects && onSelectProject && ( + )} diff --git a/packages/dashboard/app/components/ProjectSelector.css b/packages/dashboard/app/components/ProjectSelector.css index ca5d969b1b..157ffeff20 100644 --- a/packages/dashboard/app/components/ProjectSelector.css +++ b/packages/dashboard/app/components/ProjectSelector.css @@ -71,11 +71,29 @@ z-index: 100; min-width: 240px; max-width: 360px; + max-height: min(480px, calc(100vh - 120px)); + overflow-y: auto; + overscroll-behavior: contain; padding: var(--space-sm); border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-lg); + scrollbar-width: thin; + scrollbar-color: var(--text-dim) transparent; +} + +.project-selector__dropdown::-webkit-scrollbar { + width: 6px; +} + +.project-selector__dropdown::-webkit-scrollbar-track { + background: transparent; +} + +.project-selector__dropdown::-webkit-scrollbar-thumb { + background-color: var(--text-dim); + border-radius: 3px; } .project-selector-item, @@ -199,11 +217,59 @@ } .project-selector__no-results { + display: flex; + align-items: center; + gap: var(--space-xs); padding: var(--space-sm) calc(var(--space-sm) + var(--space-xs)); color: var(--text-muted); font-size: 13px; } +.project-selector__no-results-icon { + flex-shrink: 0; + opacity: 0.5; +} + +/* Autocomplete highlight — marks the matched text substring */ +.project-selector__highlight { + background: transparent; + color: inherit; + font-weight: 700; + text-decoration: underline; + text-decoration-color: var(--todo); + text-underline-offset: 2px; + text-decoration-thickness: 2px; +} + +/* Exact match indicator banner */ +.project-selector__exact-match { + padding: var(--space-xs) calc(var(--space-sm) + var(--space-xs)); + margin-bottom: var(--space-xs); + font-size: 12px; + color: var(--todo); + background: color-mix(in srgb, var(--todo) 8%, transparent); + border-radius: var(--radius-md); + text-align: center; +} + +/* Exact match badge shown on item */ +.project-selector__exact-badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 1px 5px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--todo) 15%, transparent); + color: var(--todo); + flex-shrink: 0; +} + +/* Exact match item subtle highlight */ +.project-selector__item.exact-match { + background: color-mix(in srgb, var(--todo) 5%, transparent); +} + .project-selector__footer { margin-top: var(--space-xs); padding-top: var(--space-xs); @@ -279,6 +345,44 @@ color: var(--todo); } +/* Bookmark star toggle */ +.project-selector__bookmark { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px; + background: transparent; + border: none; + border-radius: var(--radius-sm); + color: var(--text-dim); + cursor: pointer; + user-select: none; + opacity: 0; + transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast); +} + +/* Show star on row hover or when bookmarked */ +.project-selector__item:hover .project-selector__bookmark, +.project-selector__item.highlighted .project-selector__bookmark, +.project-selector__bookmark.bookmarked { + opacity: 1; +} + +.project-selector__bookmark:hover { + color: var(--todo); + background: var(--card-hover); +} + +.project-selector__bookmark.bookmarked { + color: var(--todo); +} + +.project-selector__bookmark:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); +} + /* Light theme overrides for project selector */ [data-theme="light"] .project-selector-trigger:hover { background: var(--card-hover); diff --git a/packages/dashboard/app/components/ProjectSelector.tsx b/packages/dashboard/app/components/ProjectSelector.tsx index 564b075a7f..576ed9eae8 100644 --- a/packages/dashboard/app/components/ProjectSelector.tsx +++ b/packages/dashboard/app/components/ProjectSelector.tsx @@ -1,5 +1,5 @@ import "./ProjectSelector.css"; -import { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { ChevronDown, @@ -8,30 +8,68 @@ import { Grid3X3, Search, Clock, + Star, X, } from "lucide-react"; import type { ProjectInfo } from "../api"; import type { ProjectStatus } from "@fusion/core"; import { getTrailingPath } from "../utils/pathDisplay"; import { getProjectStatusConfig, isInitializingStatus } from "../utils/projectStatusConfig"; +import { useProjectBookmarks } from "../hooks/useProjectBookmarks"; export interface ProjectSelectorProps { projects: ProjectInfo[]; currentProject: ProjectInfo | null; - onSelect: (project: ProjectInfo) => void; + onSelect?: (project: ProjectInfo) => void; onViewAll: () => void; recentProjectIds?: string[]; } /** - * ProjectSelector - Project switcher dropdown with keyboard navigation + * HighlightMatch — Renders text with matching substring highlighted (bold + accent underline). + * Used to show which part of a project name/path matches the autocomplete query. + */ +function HighlightMatch({ + text, + query, +}: { + text: string; + query: string; +}): ReactNode { + if (!query.trim()) return <>{text}; + + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + const matchIndex = lowerText.indexOf(lowerQuery); + + if (matchIndex === -1) return <>{text}; + + const before = text.slice(0, matchIndex); + const match = text.slice(matchIndex, matchIndex + query.length); + const after = text.slice(matchIndex + query.length); + + return ( + <> + {before} + {match} + {after} + + ); +} + +/** + * ProjectSelector - Project switcher dropdown with autocomplete/type-ahead * * Features: * - Dropdown trigger showing current project name + chevron + * - Always-visible search input with type-ahead filtering + * - Text highlighting showing matched portions of project names/paths * - Dropdown menu with project list, status icons, "View All Projects" option * - Keyboard navigation: arrow keys, enter to select, escape to close - * - Search/filter when 5+ projects - * - Recent projects section at top (last 3 accessed) + * - Recent projects section (last 3 accessed) + * - Bookmarked projects section (star toggle, persisted in localStorage) + * - Exact match detection: auto-highlights and Enter-selects the exact match + * - No matches state with clear messaging */ export function ProjectSelector({ projects, @@ -47,6 +85,8 @@ export function ProjectSelector({ const dropdownRef = useRef(null); const triggerRef = useRef(null); const searchInputRef = useRef(null); + const itemRefs = useRef>(new Map()); + const { bookmarkedIds, toggleBookmark, isBookmarked } = useProjectBookmarks(); // Close dropdown on outside click useEffect(() => { @@ -60,6 +100,7 @@ export function ProjectSelector({ !triggerRef.current.contains(e.target as Node) ) { setIsOpen(false); + setSearchQuery(""); } }; @@ -74,6 +115,7 @@ export function ProjectSelector({ const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { setIsOpen(false); + setSearchQuery(""); triggerRef.current?.focus(); } }; @@ -82,12 +124,12 @@ export function ProjectSelector({ return () => document.removeEventListener("keydown", handleKeyDown); }, [isOpen]); - // Focus search input when dropdown opens (if search is visible) + // Focus search input when dropdown opens (always visible for autocomplete) useEffect(() => { - if (isOpen && projects.length >= 5) { + if (isOpen) { setTimeout(() => searchInputRef.current?.focus(), 0); } - }, [isOpen, projects.length]); + }, [isOpen]); // Get recent projects const recentProjects = useMemo(() => { @@ -108,28 +150,61 @@ export function ProjectSelector({ ); }, [projects, searchQuery]); - // Organize projects for display: recent first, then others + // Detect exact match (case-insensitive name match) + const exactMatch = useMemo((): ProjectInfo | null => { + if (!searchQuery.trim()) return null; + const query = searchQuery.toLowerCase(); + // Exclude current project — it's not shown in the dropdown + const candidates = filteredProjects.filter( + (p) => p.id !== currentProject?.id + ); + const nameMatches = candidates.filter( + (p) => p.name.toLowerCase() === query + ); + if (nameMatches.length === 1) return nameMatches[0]; + return null; + }, [filteredProjects, searchQuery, currentProject]); + + // Organize projects for display: bookmarked first, then recent, then others const displayProjects = useMemo(() => { const recentIds = new Set(recentProjects.map((p) => p.id)); const currentId = currentProject?.id; + const hasSearch = Boolean(searchQuery.trim()); - // Exclude current project from list + // Bookmarked projects (excluding current) + const bookmarked = hasSearch + ? [] + : filteredProjects.filter( + (p) => + p.id !== currentId && + bookmarkedIds.has(p.id) && + !recentIds.has(p.id) + ); + + // Exclude current, bookmarked, and recent from "others" only when those + // sections are visible. Search mode surfaces every matching project here. + const bookmarkedAndRecentIds = new Set([ + ...bookmarked.map((p) => p.id), + ...(hasSearch ? [] : recentIds), + ]); const others = filteredProjects.filter( - (p) => p.id !== currentId && !recentIds.has(p.id) + (p) => p.id !== currentId && !bookmarkedAndRecentIds.has(p.id) ); return { - recent: searchQuery.trim() ? [] : recentProjects, + bookmarked, + recent: hasSearch ? [] : recentProjects, others, }; - }, [filteredProjects, recentProjects, currentProject, searchQuery]); + }, [filteredProjects, recentProjects, currentProject, searchQuery, bookmarkedIds]); // Calculate total items for keyboard navigation const totalItems = useMemo(() => { + const bookmarkedCount = displayProjects.bookmarked.length; const recentCount = displayProjects.recent.length; const othersCount = displayProjects.others.length; const viewAllCount = 1; - return recentCount + othersCount + viewAllCount; + return bookmarkedCount + recentCount + othersCount + viewAllCount; }, [displayProjects]); // Handle keyboard navigation within dropdown @@ -151,21 +226,30 @@ export function ProjectSelector({ case "Enter": e.preventDefault(); if (highlightedIndex >= 0) { + const bookmarkedCount = displayProjects.bookmarked.length; const recentCount = displayProjects.recent.length; const othersCount = displayProjects.others.length; - if (highlightedIndex < recentCount) { + if (highlightedIndex < bookmarkedCount) { + // Select bookmarked project + onSelect?.(displayProjects.bookmarked[highlightedIndex]); + } else if (highlightedIndex < bookmarkedCount + recentCount) { // Select recent project - onSelect(displayProjects.recent[highlightedIndex]); - } else if (highlightedIndex < recentCount + othersCount) { + onSelect?.(displayProjects.recent[highlightedIndex - bookmarkedCount]); + } else if (highlightedIndex < bookmarkedCount + recentCount + othersCount) { // Select other project - onSelect(displayProjects.others[highlightedIndex - recentCount]); + onSelect?.(displayProjects.others[highlightedIndex - bookmarkedCount - recentCount]); } else { // View All onViewAll(); } setIsOpen(false); setSearchQuery(""); + } else if (exactMatch) { + // Auto-select exact match on Enter when nothing is highlighted + onSelect?.(exactMatch); + setIsOpen(false); + setSearchQuery(""); } break; case "Home": @@ -178,20 +262,46 @@ export function ProjectSelector({ break; } }, - [highlightedIndex, totalItems, displayProjects, onSelect, onViewAll] + [highlightedIndex, totalItems, displayProjects, onSelect, onViewAll, exactMatch] ); - // Reset highlight when dropdown opens or search changes + // Auto-highlight first result when filtering (type-ahead behavior) useEffect(() => { - if (isOpen) { + if (isOpen && searchQuery.trim()) { + if (exactMatch) { + // Auto-highlight the exact match item + const bookmarkedCount = displayProjects.bookmarked.length; + const recentCount = displayProjects.recent.length; + const matchIdx = displayProjects.others.findIndex( + (p) => p.id === exactMatch.id + ); + if (matchIdx >= 0) { + setHighlightedIndex(bookmarkedCount + recentCount + matchIdx); + } + } else if (displayProjects.others.length > 0) { + // Highlight first item in others section + setHighlightedIndex(displayProjects.bookmarked.length + displayProjects.recent.length); + } else { + setHighlightedIndex(-1); + } + } else if (isOpen && !searchQuery.trim()) { setHighlightedIndex(-1); } - }, [isOpen, searchQuery]); + }, [isOpen, searchQuery, exactMatch, displayProjects]); + + // Scroll highlighted item into view for keyboard navigation + useEffect(() => { + if (highlightedIndex < 0) return; + const el = itemRefs.current.get(highlightedIndex); + if (el) { + el.scrollIntoView({ block: "nearest" }); + } + }, [highlightedIndex]); // Handle project selection const handleSelectProject = useCallback( (project: ProjectInfo) => { - onSelect(project); + onSelect?.(project); setIsOpen(false); setSearchQuery(""); }, @@ -207,11 +317,14 @@ export function ProjectSelector({ // Toggle dropdown const toggleDropdown = useCallback(() => { - setIsOpen((prev) => !prev); - if (isOpen) { - setSearchQuery(""); - } - }, [isOpen]); + setIsOpen((prev) => { + if (!prev) { + // Opening — always clear search for a fresh type-ahead + setSearchQuery(""); + } + return !prev; + }); + }, []); // Render status icon const renderStatusIcon = (status: ProjectStatus) => { @@ -226,6 +339,36 @@ export function ProjectSelector({ ); }; + // Render bookmark star toggle (span to avoid nested - )} + {/* Search input — always visible for autocomplete/type-ahead */} +
+ + setSearchQuery(e.target.value)} + className="project-selector__search-input" + data-testid="project-selector-search-input" + aria-label={t("projectSelector.searchAriaLabel", "Type to search projects")} + /> + {searchQuery && ( + + )} +
+ + {/* Exact match indicator */} + {exactMatch && ( +
+ {t("projectSelector.exactMatch", "Exact match — press Enter to select")}
)} - {/* Recent projects section */} - {displayProjects.recent.length > 0 && ( + {/* Bookmarked projects section */} + {displayProjects.bookmarked.length > 0 && (
- - {t("projectSelector.recent", "Recent")} + + {t("projectSelector.bookmarked", "Bookmarked")}
- {displayProjects.recent.map((project, index) => ( + {displayProjects.bookmarked.map((project, index) => (
)} - {/* All projects section */} -
- {displayProjects.recent.length > 0 && ( + {/* Recent projects section */} + {displayProjects.recent.length > 0 && ( +
- - {t("projectSelector.allProjects", "All Projects")} + + {t("projectSelector.recent", "Recent")}
- )} - - {displayProjects.others.length === 0 && searchQuery ? ( -
- {t("projectSelector.noResults", "No projects match your search")} -
- ) : ( - displayProjects.others.map((project, index) => { - const actualIndex = displayProjects.recent.length + index; + {displayProjects.recent.map((project, index) => { + const actualIndex = displayProjects.bookmarked.length + index; return ( + ); + })} +
+ )} + + {/* All projects section */} +
+ {(displayProjects.bookmarked.length > 0 || displayProjects.recent.length > 0) && ( +
+ + {t("projectSelector.allProjects", "All Projects")} +
+ )} + + {displayProjects.others.length === 0 && searchQuery ? ( +
+ + + {t("projectSelector.noResults", "No projects match \"{{query}}\"", { query: searchQuery })} + +
+ ) : ( + displayProjects.others.map((project, index) => { + const actualIndex = displayProjects.bookmarked.length + displayProjects.recent.length + index; + const isExactMatch = exactMatch?.id === project.id; + return ( +