Compare commits

...

10 Commits

Author SHA1 Message Date
LUIS NOVO fb69341490 fix(command-palette): prioritize Search/Ask when no command matches
- Show Search/Ask options FIRST when query doesn't match any navigation
- Add setTimeout to ensure dialog closes before navigation
- Reset query when dialog closes
- Use forceMount to ensure Search/Ask options are always selectable
- Show Search/Ask at bottom when there ARE matching commands
2025-11-27 12:41:57 -03:00
LUIS NOVO 87c22daa3b feat(quick-search): replace top bar with command palette (⌘K)
- Remove persistent top bar header (saves 48px vertical space)
- Add CommandPalette component triggered by ⌘K / Ctrl+K
- Fuzzy match navigation items: Sources, Notebooks, Podcasts, etc.
- Show "Search for X" and "Ask about X" for custom queries
- Add create actions: Source, Notebook, Podcast
- Lift create dialog state to AppShell for shared access
- Add ⌘K keyboard hint in sidebar footer
- Delete unused QuickSearch component
2025-11-27 12:36:52 -03:00
LUIS NOVO 2ca39348af feat(quick-search): add Ask mode shortcut and improve robustness
- Add Shift+Enter shortcut to trigger Ask mode from quick search
- Add Suspense boundary for useSearchParams() hydration safety
- Handle mode URL parameter to auto-trigger Ask or Search
- Reduce header height from h-16 to h-12 for more content space
- Remove redundant flex-1 spacer div in AppShell
- Fix code style: single quotes, trailing newline
- Add aria-label for accessibility
- Update placeholder to hint about Shift modifier
2025-11-27 12:05:48 -03:00
Embroider Snow aa694dcfe2
Update frontend/src/app/(dashboard)/search/page.tsx
Remove the !searchMutation.isPending gate

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-11-27 21:34:35 +08:00
EmbroiderSnow e6b212a3a4 Add some comment in QuickSearch component 2025-11-27 21:09:16 +08:00
EmbroiderSnow 3bcb8c30f9 Disable npm lint warning (useEffect is designed to only depend on initialQuery on purpose) 2025-11-27 20:58:31 +08:00
EmbroiderSnow 60410f1407 fix: QuickSearch not triggered under search page 2025-11-27 20:41:10 +08:00
EmbroiderSnow bbcf80b8e6 Add QuickSearch to layout 2025-11-27 20:35:13 +08:00
EmbroiderSnow 79cd88b150 Implement param parse in /(dashboard)/search 2025-11-27 20:34:48 +08:00
EmbroiderSnow 50c067b4df Implement QuickSearch component 2025-11-27 20:34:04 +08:00
4 changed files with 347 additions and 29 deletions

View File

@ -1,6 +1,7 @@
'use client'
import { useMemo, useState } from 'react'
import { Suspense, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { AppShell } from '@/components/layout/AppShell'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
@ -22,7 +23,7 @@ import { StreamingResponse } from '@/components/search/StreamingResponse'
import { AdvancedModelsDialog } from '@/components/search/AdvancedModelsDialog'
import { SaveToNotebooksDialog } from '@/components/search/SaveToNotebooksDialog'
export default function SearchPage() {
function SearchPageContent() {
// Search state
const [searchQuery, setSearchQuery] = useState('')
const [searchType, setSearchType] = useState<'text' | 'vector'>('text')
@ -43,6 +44,16 @@ export default function SearchPage() {
// Save to notebooks dialog
const [showSaveDialog, setShowSaveDialog] = useState(false)
// Get URL params
const searchParams = useSearchParams()
const initialQuery = searchParams.get('q')
const initialMode = searchParams.get('mode') // 'ask' or 'search'
// Tab state - controlled to support URL-driven mode switching
const [activeTab, setActiveTab] = useState<string>(
initialMode === 'ask' ? 'ask' : (initialQuery ? 'search' : 'ask')
)
// Hooks
const searchMutation = useSearch()
const ask = useAsk()
@ -57,6 +68,50 @@ export default function SearchPage() {
return new Map(availableModels.map((model) => [model.id, model.name]))
}, [availableModels])
// Handle URL-driven search/ask on mount or param change
useEffect(() => {
if (initialQuery) {
if (initialMode === 'ask') {
// Set the question and trigger ask
setAskQuestion(initialQuery)
setActiveTab('ask')
} else {
// Default to search
setSearchQuery(initialQuery)
setActiveTab('search')
searchMutation.mutate({
query: initialQuery,
type: searchType,
limit: 100,
search_sources: searchSources,
search_notes: searchNotes,
minimum_score: 0.2
})
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialQuery, initialMode])
// Auto-trigger ask when question is set from URL and models are loaded
useEffect(() => {
if (
initialMode === 'ask' &&
initialQuery &&
askQuestion === initialQuery &&
modelDefaults?.default_chat_model &&
!ask.isStreaming &&
!ask.finalAnswer
) {
const models = {
strategy: modelDefaults.default_chat_model,
answer: modelDefaults.default_chat_model,
finalAnswer: modelDefaults.default_chat_model
}
ask.sendAsk(initialQuery, models)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialMode, initialQuery, askQuestion, modelDefaults?.default_chat_model])
const resolveModelName = (id?: string | null) => {
if (!id) return 'Not set'
return modelNameById.get(id) ?? id
@ -100,7 +155,7 @@ export default function SearchPage() {
<div className="p-4 md:p-6">
<h1 className="text-xl md:text-2xl font-bold mb-4 md:mb-6">Ask and Search</h1>
<Tabs defaultValue="ask" className="w-full space-y-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full space-y-6">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Choose a mode</p>
<TabsList aria-label="Ask or search your knowledge base" className="w-full max-w-xl">
@ -423,3 +478,18 @@ export default function SearchPage() {
</AppShell>
)
}
// Wrap in Suspense for useSearchParams() hydration safety
export default function SearchPage() {
return (
<Suspense fallback={
<AppShell>
<div className="p-4 md:p-6 flex items-center justify-center">
<LoadingSpinner size="lg" />
</div>
</AppShell>
}>
<SearchPageContent />
</Suspense>
)
}

View File

@ -0,0 +1,216 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
} from '@/components/ui/command'
import {
Book,
Search,
Mic,
Bot,
Shuffle,
Settings,
FileText,
Wrench,
MessageCircleQuestion,
Plus,
} from 'lucide-react'
const navigationItems = [
{ name: 'Sources', href: '/sources', icon: FileText, keywords: ['files', 'documents', 'upload'] },
{ name: 'Notebooks', href: '/notebooks', icon: Book, keywords: ['notes', 'research', 'projects'] },
{ name: 'Ask and Search', href: '/search', icon: Search, keywords: ['find', 'query'] },
{ name: 'Podcasts', href: '/podcasts', icon: Mic, keywords: ['audio', 'episodes', 'generate'] },
{ name: 'Models', href: '/models', icon: Bot, keywords: ['ai', 'llm', 'providers', 'openai', 'anthropic'] },
{ name: 'Transformations', href: '/transformations', icon: Shuffle, keywords: ['prompts', 'templates', 'actions'] },
{ name: 'Settings', href: '/settings', icon: Settings, keywords: ['preferences', 'config', 'options'] },
{ name: 'Advanced', href: '/advanced', icon: Wrench, keywords: ['debug', 'system', 'tools'] },
]
const createItems = [
{ name: 'Create Source', action: 'source', icon: FileText },
{ name: 'Create Notebook', action: 'notebook', icon: Book },
{ name: 'Create Podcast', action: 'podcast', icon: Mic },
]
interface CommandPaletteProps {
onCreateSource?: () => void
onCreateNotebook?: () => void
onCreatePodcast?: () => void
}
export function CommandPalette({
onCreateSource,
onCreateNotebook,
onCreatePodcast,
}: CommandPaletteProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const router = useRouter()
// Global keyboard listener for ⌘K / Ctrl+K
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setOpen((open) => !open)
}
}
document.addEventListener('keydown', down)
return () => document.removeEventListener('keydown', down)
}, [])
// Reset query when dialog closes
useEffect(() => {
if (!open) {
setQuery('')
}
}, [open])
const handleSelect = useCallback((callback: () => void) => {
setOpen(false)
setQuery('')
// Use setTimeout to ensure dialog closes before navigation
setTimeout(callback, 0)
}, [])
const handleNavigate = useCallback((href: string) => {
handleSelect(() => router.push(href))
}, [handleSelect, router])
const handleSearch = useCallback(() => {
if (!query.trim()) return
handleSelect(() => router.push(`/search?q=${encodeURIComponent(query)}&mode=search`))
}, [handleSelect, router, query])
const handleAsk = useCallback(() => {
if (!query.trim()) return
handleSelect(() => router.push(`/search?q=${encodeURIComponent(query)}&mode=ask`))
}, [handleSelect, router, query])
const handleCreate = useCallback((action: string) => {
handleSelect(() => {
if (action === 'source' && onCreateSource) onCreateSource()
else if (action === 'notebook' && onCreateNotebook) onCreateNotebook()
else if (action === 'podcast' && onCreatePodcast) onCreatePodcast()
})
}, [handleSelect, onCreateSource, onCreateNotebook, onCreatePodcast])
// Check if query matches any command (navigation or create)
const queryLower = query.toLowerCase().trim()
const hasCommandMatch = queryLower && (
navigationItems.some(item =>
item.name.toLowerCase().includes(queryLower) ||
item.keywords.some(k => k.includes(queryLower))
) ||
createItems.some(item =>
item.name.toLowerCase().includes(queryLower)
)
)
return (
<CommandDialog
open={open}
onOpenChange={setOpen}
title="Command Palette"
description="Navigate, search, or ask your knowledge base"
>
<CommandInput
placeholder="Type a command or search..."
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>
<div className="text-sm text-muted-foreground">
No commands found.
</div>
</CommandEmpty>
{/* Search/Ask - show FIRST when there's a query with no command match */}
{query.trim() && !hasCommandMatch && (
<CommandGroup heading="Search & Ask" forceMount>
<CommandItem
value={`__search__ ${query}`}
onSelect={handleSearch}
forceMount
>
<Search className="h-4 w-4" />
<span>Search for &ldquo;{query}&rdquo;</span>
</CommandItem>
<CommandItem
value={`__ask__ ${query}`}
onSelect={handleAsk}
forceMount
>
<MessageCircleQuestion className="h-4 w-4" />
<span>Ask about &ldquo;{query}&rdquo;</span>
</CommandItem>
</CommandGroup>
)}
{/* Navigation */}
<CommandGroup heading="Navigation">
{navigationItems.map((item) => (
<CommandItem
key={item.href}
value={`${item.name} ${item.keywords.join(' ')}`}
onSelect={() => handleNavigate(item.href)}
>
<item.icon className="h-4 w-4" />
<span>{item.name}</span>
</CommandItem>
))}
</CommandGroup>
{/* Create */}
<CommandGroup heading="Create">
{createItems.map((item) => (
<CommandItem
key={item.action}
value={`create ${item.name}`}
onSelect={() => handleCreate(item.action)}
>
<Plus className="h-4 w-4" />
<span>{item.name}</span>
</CommandItem>
))}
</CommandGroup>
{/* Search/Ask - also show at bottom when there IS a command match */}
{query.trim() && hasCommandMatch && (
<>
<CommandSeparator />
<CommandGroup heading="Or search your knowledge base" forceMount>
<CommandItem
value={`__search__ ${query}`}
onSelect={handleSearch}
forceMount
>
<Search className="h-4 w-4" />
<span>Search for &ldquo;{query}&rdquo;</span>
</CommandItem>
<CommandItem
value={`__ask__ ${query}`}
onSelect={handleAsk}
forceMount
>
<MessageCircleQuestion className="h-4 w-4" />
<span>Ask about &ldquo;{query}&rdquo;</span>
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</CommandDialog>
)
}

View File

@ -1,20 +1,44 @@
'use client'
import { useState } from 'react'
import { AppSidebar } from './AppSidebar'
import { CommandPalette } from '@/components/common/CommandPalette'
import { AddSourceDialog } from '@/components/sources/AddSourceDialog'
import { CreateNotebookDialog } from '@/components/notebooks/CreateNotebookDialog'
import { GeneratePodcastDialog } from '@/components/podcasts/GeneratePodcastDialog'
interface AppShellProps {
children: React.ReactNode
}
export function AppShell({ children }: AppShellProps) {
// Lifted dialog state for both sidebar and command palette
const [sourceDialogOpen, setSourceDialogOpen] = useState(false)
const [notebookDialogOpen, setNotebookDialogOpen] = useState(false)
const [podcastDialogOpen, setPodcastDialogOpen] = useState(false)
return (
<div className="flex h-screen overflow-hidden">
<AppSidebar />
<div className="flex-1 flex flex-col min-h-0">
<main className="flex-1 flex flex-col min-h-0 overflow-hidden">
{children}
</main>
</div>
<AppSidebar
onCreateSource={() => setSourceDialogOpen(true)}
onCreateNotebook={() => setNotebookDialogOpen(true)}
onCreatePodcast={() => setPodcastDialogOpen(true)}
/>
<main className="flex-1 flex flex-col min-h-0 overflow-hidden">
{children}
</main>
{/* Command Palette - accessible via ⌘K */}
<CommandPalette
onCreateSource={() => setSourceDialogOpen(true)}
onCreateNotebook={() => setNotebookDialogOpen(true)}
onCreatePodcast={() => setPodcastDialogOpen(true)}
/>
{/* Create Dialogs */}
<AddSourceDialog open={sourceDialogOpen} onOpenChange={setSourceDialogOpen} />
<CreateNotebookDialog open={notebookDialogOpen} onOpenChange={setNotebookDialogOpen} />
<GeneratePodcastDialog open={podcastDialogOpen} onOpenChange={setPodcastDialogOpen} />
</div>
)
}

View File

@ -22,9 +22,6 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { ThemeToggle } from '@/components/common/ThemeToggle'
import { AddSourceDialog } from '@/components/sources/AddSourceDialog'
import { CreateNotebookDialog } from '@/components/notebooks/CreateNotebookDialog'
import { GeneratePodcastDialog } from '@/components/podcasts/GeneratePodcastDialog'
import { Separator } from '@/components/ui/separator'
import {
Book,
@ -39,6 +36,7 @@ import {
FileText,
Plus,
Wrench,
Command,
} from 'lucide-react'
const navigation = [
@ -74,25 +72,32 @@ const navigation = [
type CreateTarget = 'source' | 'notebook' | 'podcast'
export function AppSidebar() {
interface AppSidebarProps {
onCreateSource: () => void
onCreateNotebook: () => void
onCreatePodcast: () => void
}
export function AppSidebar({
onCreateSource,
onCreateNotebook,
onCreatePodcast,
}: AppSidebarProps) {
const pathname = usePathname()
const { logout } = useAuth()
const { isCollapsed, toggleCollapse } = useSidebarStore()
const [createMenuOpen, setCreateMenuOpen] = useState(false)
const [sourceDialogOpen, setSourceDialogOpen] = useState(false)
const [notebookDialogOpen, setNotebookDialogOpen] = useState(false)
const [podcastDialogOpen, setPodcastDialogOpen] = useState(false)
const handleCreateSelection = (target: CreateTarget) => {
setCreateMenuOpen(false)
if (target === 'source') {
setSourceDialogOpen(true)
onCreateSource()
} else if (target === 'notebook') {
setNotebookDialogOpen(true)
onCreateNotebook()
} else if (target === 'podcast') {
setPodcastDialogOpen(true)
onCreatePodcast()
}
}
@ -289,6 +294,19 @@ export function AppSidebar() {
isCollapsed && 'px-2'
)}
>
{/* Command Palette hint */}
{!isCollapsed && (
<div className="flex items-center justify-between px-3 py-1.5 text-xs text-sidebar-foreground/60">
<span className="flex items-center gap-1.5">
<Command className="h-3 w-3" />
Quick actions
</span>
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
<span className="text-xs"></span>K
</kbd>
</div>
)}
<div
className={cn(
'flex',
@ -334,16 +352,6 @@ export function AppSidebar() {
)}
</div>
</div>
<AddSourceDialog open={sourceDialogOpen} onOpenChange={setSourceDialogOpen} />
<CreateNotebookDialog
open={notebookDialogOpen}
onOpenChange={setNotebookDialogOpen}
/>
<GeneratePodcastDialog
open={podcastDialogOpen}
onOpenChange={setPodcastDialogOpen}
/>
</TooltipProvider>
)
}