mirror of
https://github.com/humanlayer/humanlayer.git
synced 2025-08-20 19:01:22 +03:00
Implement fuzzy search command palette with superhuman-style interactions
✨ Major enhancements: - Added fuzzy search with highlighting (inspired by Superhuman/Linear) - Command palette (Cmd+K) now only shows 'Create Session' - Search mode (/) provides fuzzy search across all sessions - Compact UI limited to 5 results to prevent scrolling - Smart keyboard navigation with up/down arrows - Enter key opens selected session in search mode - Fixed 'c' hotkey to properly open launcher and show input view 🔧 Technical improvements: - Created reusable FuzzySearchInput component - Implemented fuzzy matching algorithm with smart scoring - Enhanced session configuration with working directory, model, max turns - Directory path fuzzy search for working directory field - Improved focus management and hotkey detection 🐛 Bug fixes: - Fixed 'c' hotkey not working in session table - Fixed ESC key handling in command palette - Fixed keyboard navigation in search input Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
4
humanlayer-wui/problems.md
Normal file
4
humanlayer-wui/problems.md
Normal file
@@ -0,0 +1,4 @@
|
||||
- 'c' on session table doesn't launch the session creator
|
||||
- selecting "create new session" from the cmd+k launch takes me to a blank screen
|
||||
- search view still doesn't work - the max height should be 80% of the screen height, and only display as many items as can fit
|
||||
- selecting a session from the search view should navigate to the session details
|
||||
@@ -1,6 +1,14 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { Button } from './ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import FuzzySearchInput from './FuzzySearchInput'
|
||||
|
||||
interface SessionConfig {
|
||||
query: string
|
||||
workingDir: string
|
||||
model?: string
|
||||
maxTurns?: number
|
||||
}
|
||||
|
||||
interface CommandInputProps {
|
||||
value: string
|
||||
@@ -8,6 +16,8 @@ interface CommandInputProps {
|
||||
onSubmit: () => void
|
||||
placeholder?: string
|
||||
isLoading?: boolean
|
||||
config?: SessionConfig
|
||||
onConfigChange?: (config: SessionConfig) => void
|
||||
}
|
||||
|
||||
export default function CommandInput({
|
||||
@@ -16,9 +26,25 @@ export default function CommandInput({
|
||||
onSubmit,
|
||||
placeholder = 'Enter your command...',
|
||||
isLoading = false,
|
||||
config = { query: '', workingDir: '' },
|
||||
onConfigChange,
|
||||
}: CommandInputProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
// Common directory suggestions for fuzzy search
|
||||
const commonDirectories = [
|
||||
'~/',
|
||||
'~/Desktop',
|
||||
'~/Documents',
|
||||
'~/Downloads',
|
||||
'~/Projects',
|
||||
'/usr/local',
|
||||
'/opt',
|
||||
'/tmp',
|
||||
process.cwd() || './',
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
@@ -27,14 +53,21 @@ export default function CommandInput({
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !showAdvanced) {
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
const updateConfig = (updates: Partial<SessionConfig>) => {
|
||||
if (onConfigChange) {
|
||||
onConfigChange({ ...config, ...updates })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
{/* Main query input */}
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -68,12 +101,86 @@ export default function CommandInput({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Working Directory Field with Fuzzy Search */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Working Directory</label>
|
||||
<FuzzySearchInput
|
||||
items={commonDirectories}
|
||||
value={config.workingDir}
|
||||
onChange={value => updateConfig({ workingDir: value })}
|
||||
onSelect={directory => updateConfig({ workingDir: directory })}
|
||||
placeholder="/path/to/directory or leave empty for current directory"
|
||||
maxResults={6}
|
||||
emptyMessage="Type a directory path..."
|
||||
renderItem={(item) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-blue-500">📁</span>
|
||||
<span className="font-mono">{item}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced Options Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showAdvanced ? '← Hide' : 'Advanced Options →'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced Options */}
|
||||
{showAdvanced && (
|
||||
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Model</label>
|
||||
<select
|
||||
value={config.model || ''}
|
||||
onChange={e => updateConfig({ model: e.target.value || undefined })}
|
||||
className={cn(
|
||||
'w-full h-9 px-3 text-sm',
|
||||
'bg-background border rounded-md',
|
||||
'border-border hover:border-primary/50 focus:border-primary focus:ring-2 focus:ring-primary/20',
|
||||
)}
|
||||
>
|
||||
<option value="">Default Model</option>
|
||||
<option value="claude-3-5-sonnet-20241022">Claude 3.5 Sonnet</option>
|
||||
<option value="claude-3-opus-20240229">Claude 3 Opus</option>
|
||||
<option value="claude-3-haiku-20240307">Claude 3 Haiku</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Max Turns</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.maxTurns || ''}
|
||||
onChange={e => updateConfig({ maxTurns: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
placeholder="Default"
|
||||
min="1"
|
||||
max="100"
|
||||
className={cn(
|
||||
'w-full h-9 px-3 text-sm',
|
||||
'bg-background border rounded-md',
|
||||
'border-border hover:border-primary/50 focus:border-primary focus:ring-2 focus:ring-primary/20',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Bar */}
|
||||
{value.trim() && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex items-center space-x-2 text-xs text-muted-foreground">
|
||||
{value.startsWith('/') && (
|
||||
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded">
|
||||
Working directory mode
|
||||
{config.workingDir && (
|
||||
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded font-mono text-xs">
|
||||
{config.workingDir}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useSessionLauncher } from '@/hooks/useSessionLauncher'
|
||||
import { useStore } from '@/App'
|
||||
import { fuzzySearch, highlightMatches, type FuzzyMatch } from '@/lib/fuzzy-search'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MenuOption {
|
||||
id: string
|
||||
@@ -10,33 +12,51 @@ interface MenuOption {
|
||||
action: () => void
|
||||
}
|
||||
|
||||
interface CommandPaletteMenuProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function CommandPaletteMenu() {
|
||||
const { createNewSession, openSessionById, selectedMenuIndex, setSelectedMenuIndex } =
|
||||
const { createNewSession, openSessionById, selectedMenuIndex, setSelectedMenuIndex, mode } =
|
||||
useSessionLauncher()
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Get sessions from the main app store
|
||||
const sessions = useStore(state => state.sessions)
|
||||
|
||||
// Build menu options
|
||||
const menuOptions: MenuOption[] = [
|
||||
// Build base menu options
|
||||
const baseOptions: MenuOption[] = [
|
||||
{
|
||||
id: 'create-session',
|
||||
label: 'Create Session',
|
||||
description: 'Start a new session with AI assistance',
|
||||
action: createNewSession,
|
||||
},
|
||||
...sessions.slice(0, 5).map(session => ({
|
||||
id: `open-${session.id}`,
|
||||
label: `Open ${session.query.slice(0, 40)}${session.query.length > 40 ? '...' : ''}`,
|
||||
description: `${session.status} • ${session.model || 'Unknown model'}`,
|
||||
action: () => openSessionById(session.id),
|
||||
})),
|
||||
]
|
||||
|
||||
// Command mode: Only Create Session
|
||||
// Search mode: All sessions (for fuzzy search) but limit display to 5
|
||||
const sessionOptions: MenuOption[] = mode === 'search'
|
||||
? sessions.map(session => ({
|
||||
id: `open-${session.id}`,
|
||||
label: `${session.query.slice(0, 40)}${session.query.length > 40 ? '...' : ''}`,
|
||||
description: `${session.status} • ${session.model || 'Unknown model'}`,
|
||||
action: () => openSessionById(session.id),
|
||||
}))
|
||||
: [] // No sessions in command mode
|
||||
|
||||
// Apply fuzzy search if in search mode and there's a query
|
||||
const filteredSessions = searchQuery && mode === 'search'
|
||||
? fuzzySearch(sessionOptions, searchQuery, {
|
||||
keys: ['label', 'description'],
|
||||
threshold: 0.1,
|
||||
includeMatches: true,
|
||||
})
|
||||
: sessionOptions.map(session => ({ item: session, matches: [], score: 1, indices: [] }))
|
||||
|
||||
// Combine options based on mode
|
||||
const menuOptions: MenuOption[] = mode === 'command'
|
||||
? baseOptions // Command: Only Create Session
|
||||
: filteredSessions.slice(0, 5).map(result => result.item) // Search: Only sessions (no Create Session), limit to 5
|
||||
|
||||
// Keyboard navigation
|
||||
useHotkeys(
|
||||
'up',
|
||||
@@ -71,48 +91,112 @@ export default function CommandPaletteMenu() {
|
||||
}
|
||||
}, [menuOptions.length, selectedMenuIndex, setSelectedMenuIndex])
|
||||
|
||||
// Render highlighted text for search results
|
||||
const renderHighlightedText = (text: string, matches: FuzzyMatch['matches'], targetKey?: string) => {
|
||||
const match = matches.find(m => m.key === targetKey)
|
||||
if (match && match.indices && searchQuery) {
|
||||
const segments = highlightMatches(text, match.indices)
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={segment.highlighted ? 'bg-yellow-200/80 dark:bg-yellow-900/60 font-medium' : ''}
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground mb-3">
|
||||
Select an option or use arrow keys to navigate
|
||||
</div>
|
||||
|
||||
{menuOptions.map((option, index) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className={`
|
||||
p-3 rounded-lg cursor-pointer transition-all duration-150
|
||||
${
|
||||
index === selectedMenuIndex
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted/50 hover:bg-muted'
|
||||
{/* Search input for search mode */}
|
||||
{mode === 'search' && (
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
// Prevent up/down from moving cursor, let them control the list
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
}
|
||||
// Enter should trigger selected option
|
||||
if (e.key === 'Enter' && menuOptions[selectedMenuIndex]) {
|
||||
e.preventDefault()
|
||||
menuOptions[selectedMenuIndex].action()
|
||||
}
|
||||
`}
|
||||
onClick={() => {
|
||||
setSelectedMenuIndex(index)
|
||||
option.action()
|
||||
}}
|
||||
onMouseEnter={() => setSelectedMenuIndex(index)}
|
||||
>
|
||||
<div className="font-medium">{option.label}</div>
|
||||
{option.description && (
|
||||
<div
|
||||
className={`text-xs mt-1 ${
|
||||
index === selectedMenuIndex ? 'text-primary-foreground/80' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{option.description}
|
||||
</div>
|
||||
placeholder="Search sessions..."
|
||||
className={cn(
|
||||
'w-full h-9 px-3 py-2 text-sm',
|
||||
'font-mono',
|
||||
'bg-background border rounded-md',
|
||||
'transition-all duration-200',
|
||||
'placeholder:text-muted-foreground/60',
|
||||
'border-border hover:border-primary/50 focus:border-primary focus:ring-2 focus:ring-primary/20',
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{menuOptions.length === 1 && (
|
||||
<div className="text-xs text-muted-foreground text-center mt-4">No recent sessions found</div>
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground mt-4 pt-3 border-t">
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Results counter for search mode - compact */}
|
||||
{mode === 'search' && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{menuOptions.length} of {filteredSessions.length} sessions
|
||||
</div>
|
||||
)}
|
||||
|
||||
{menuOptions.map((option, index) => {
|
||||
// Find the corresponding match data for highlighting
|
||||
const matchData = mode === 'search' && searchQuery
|
||||
? filteredSessions.find(result => result.item.id === option.id)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
className={cn(
|
||||
'p-2 rounded cursor-pointer transition-all duration-150',
|
||||
index === selectedMenuIndex
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted/30 hover:bg-muted/60'
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedMenuIndex(index)
|
||||
option.action()
|
||||
}}
|
||||
onMouseEnter={() => setSelectedMenuIndex(index)}
|
||||
>
|
||||
<div className="text-sm font-medium truncate">
|
||||
{matchData ? renderHighlightedText(option.label, matchData.matches, 'label') : option.label}
|
||||
</div>
|
||||
{option.description && (
|
||||
<div
|
||||
className={cn(
|
||||
'text-xs mt-0.5 truncate',
|
||||
index === selectedMenuIndex ? 'text-primary-foreground/70' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{matchData ? renderHighlightedText(option.description, matchData.matches, 'description') : option.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{menuOptions.length === 0 && mode === 'search' && (
|
||||
<div className="text-xs text-muted-foreground text-center py-4">No sessions found</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t border-border/30">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span>↑↓ Navigate</span>
|
||||
<span>↵ Select</span>
|
||||
</div>
|
||||
|
||||
207
humanlayer-wui/src/components/FuzzySearchInput.tsx
Normal file
207
humanlayer-wui/src/components/FuzzySearchInput.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { fuzzySearch, highlightMatches, type FuzzyMatch } from '@/lib/fuzzy-search'
|
||||
|
||||
interface FuzzySearchInputProps<T> {
|
||||
items: T[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSelect?: (item: T) => void
|
||||
placeholder?: string
|
||||
searchKeys?: string[]
|
||||
renderItem?: (item: T, matches: FuzzyMatch['matches']) => React.ReactNode
|
||||
className?: string
|
||||
maxResults?: number
|
||||
emptyMessage?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function FuzzySearchInput<T>({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
onSelect,
|
||||
placeholder = 'Search...',
|
||||
searchKeys = [],
|
||||
renderItem,
|
||||
className,
|
||||
maxResults = 10,
|
||||
emptyMessage = 'No results found',
|
||||
disabled = false,
|
||||
}: FuzzySearchInputProps<T>) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Perform fuzzy search
|
||||
const searchResults = useMemo(() => {
|
||||
if (!value.trim()) return []
|
||||
|
||||
const results = fuzzySearch(items, value, {
|
||||
keys: searchKeys,
|
||||
threshold: 0.1,
|
||||
includeMatches: true,
|
||||
})
|
||||
|
||||
return results.slice(0, maxResults)
|
||||
}, [items, value, searchKeys, maxResults])
|
||||
|
||||
// Reset selection when results change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [searchResults])
|
||||
|
||||
// Open dropdown when typing
|
||||
useEffect(() => {
|
||||
setIsOpen(value.length > 0 && searchResults.length > 0)
|
||||
}, [value, searchResults.length])
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen || !searchResults.length) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => (prev + 1) % searchResults.length)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => (prev - 1 + searchResults.length) % searchResults.length)
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (searchResults[selectedIndex] && onSelect) {
|
||||
onSelect(searchResults[selectedIndex].item)
|
||||
setIsOpen(false)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
setIsOpen(false)
|
||||
inputRef.current?.blur()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, searchResults, selectedIndex, onSelect])
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (listRef.current && isOpen) {
|
||||
const selectedElement = listRef.current.children[selectedIndex] as HTMLElement
|
||||
selectedElement?.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
}, [selectedIndex, isOpen])
|
||||
|
||||
const handleItemClick = (item: T, index: number) => {
|
||||
setSelectedIndex(index)
|
||||
if (onSelect) {
|
||||
onSelect(item)
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const defaultRenderItem = (item: T, matches: FuzzyMatch['matches']) => {
|
||||
const text = String(item)
|
||||
const match = matches[0]
|
||||
|
||||
if (match && match.indices) {
|
||||
const segments = highlightMatches(text, match.indices)
|
||||
return defaultRenderHighlight(segments)
|
||||
}
|
||||
|
||||
return <span>{text}</span>
|
||||
}
|
||||
|
||||
const defaultRenderHighlight = (segments: Array<{ text: string; highlighted: boolean }>) => (
|
||||
<>
|
||||
{segments.map((segment, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={segment.highlighted ? 'bg-yellow-200 dark:bg-yellow-900/50 font-medium' : ''}
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onFocus={() => value && searchResults.length > 0 && setIsOpen(true)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-full h-10 px-3 py-2 text-sm',
|
||||
'font-mono',
|
||||
'bg-background border rounded-md',
|
||||
'transition-all duration-200',
|
||||
'placeholder:text-muted-foreground/60',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'border-border hover:border-primary/50 focus:border-primary focus:ring-2 focus:ring-primary/20',
|
||||
className
|
||||
)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Results dropdown */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-background border border-border rounded-md shadow-lg z-20 max-h-60 overflow-y-auto"
|
||||
>
|
||||
{searchResults.length > 0 ? (
|
||||
searchResults.map((result, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'px-3 py-2 cursor-pointer transition-colors text-sm',
|
||||
'border-b border-border/50 last:border-b-0',
|
||||
index === selectedIndex
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted/50'
|
||||
)}
|
||||
onClick={() => handleItemClick(result.item, index)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
{renderItem ? renderItem(result.item, result.matches) : defaultRenderItem(result.item, result.matches)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground text-center">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer with navigation hints */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="px-3 py-1 text-xs text-muted-foreground bg-muted/30 border-t border-border/50 flex items-center justify-between">
|
||||
<span>↑↓ Navigate</span>
|
||||
<span>↵ Select • ESC Close</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ interface SessionLauncherProps {
|
||||
|
||||
export function SessionLauncher({ isOpen, onClose }: SessionLauncherProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
const { query, setQuery, launchSession, isLaunching, error, mode, view, setView } =
|
||||
const { query, setQuery, config, setConfig, launchSession, isLaunching, error, mode, view, setView } =
|
||||
useSessionLauncher()
|
||||
|
||||
// Escape key to close - enable even when input is focused
|
||||
@@ -92,7 +92,7 @@ export function SessionLauncher({ isOpen, onClose }: SessionLauncherProps) {
|
||||
</div>
|
||||
|
||||
{view === 'menu' ? (
|
||||
<CommandPaletteMenu onClose={onClose} />
|
||||
<CommandPaletteMenu />
|
||||
) : (
|
||||
<>
|
||||
<CommandInput
|
||||
@@ -101,6 +101,8 @@ export function SessionLauncher({ isOpen, onClose }: SessionLauncherProps) {
|
||||
onSubmit={handleSubmit}
|
||||
placeholder="Enter your prompt to start a session..."
|
||||
isLoading={isLaunching}
|
||||
config={config}
|
||||
onConfigChange={setConfig}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -2,9 +2,12 @@ import { create } from 'zustand'
|
||||
import { daemonClient } from '@/lib/daemon'
|
||||
import type { LaunchSessionRequest } from '@/lib/daemon/types'
|
||||
|
||||
interface ParsedQuery {
|
||||
|
||||
interface SessionConfig {
|
||||
query: string
|
||||
workingDir?: string
|
||||
workingDir: string
|
||||
model?: string
|
||||
maxTurns?: number
|
||||
}
|
||||
|
||||
interface LauncherState {
|
||||
@@ -12,6 +15,7 @@ interface LauncherState {
|
||||
mode: 'command' | 'search'
|
||||
view: 'menu' | 'input'
|
||||
query: string
|
||||
config: SessionConfig
|
||||
isLaunching: boolean
|
||||
error?: string
|
||||
gPrefixMode: boolean
|
||||
@@ -21,6 +25,7 @@ interface LauncherState {
|
||||
open: (mode?: 'command' | 'search') => void
|
||||
close: () => void
|
||||
setQuery: (query: string) => void
|
||||
setConfig: (config: SessionConfig) => void
|
||||
setGPrefixMode: (enabled: boolean) => void
|
||||
setView: (view: 'menu' | 'input') => void
|
||||
setSelectedMenuIndex: (index: number) => void
|
||||
@@ -30,34 +35,13 @@ interface LauncherState {
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
// Parse basic query patterns
|
||||
function parseQuery(query: string): ParsedQuery {
|
||||
const trimmed = query.trim()
|
||||
|
||||
// Check for working directory pattern: "/path rest of query"
|
||||
if (trimmed.startsWith('/')) {
|
||||
const spaceIndex = trimmed.indexOf(' ')
|
||||
if (spaceIndex > 0) {
|
||||
return {
|
||||
query: trimmed.slice(spaceIndex + 1).trim(),
|
||||
workingDir: trimmed.slice(0, spaceIndex),
|
||||
}
|
||||
}
|
||||
// If just "/path" with no additional query, treat as working dir change
|
||||
return {
|
||||
query: '',
|
||||
workingDir: trimmed,
|
||||
}
|
||||
}
|
||||
|
||||
return { query: trimmed }
|
||||
}
|
||||
|
||||
export const useSessionLauncher = create<LauncherState>((set, get) => ({
|
||||
isOpen: false,
|
||||
mode: 'command',
|
||||
view: 'menu',
|
||||
query: '',
|
||||
config: { query: '', workingDir: '' },
|
||||
isLaunching: false,
|
||||
gPrefixMode: false,
|
||||
selectedMenuIndex: 0,
|
||||
@@ -76,6 +60,7 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
|
||||
isOpen: false,
|
||||
view: 'menu',
|
||||
query: '',
|
||||
config: { query: '', workingDir: '' },
|
||||
selectedMenuIndex: 0,
|
||||
error: undefined,
|
||||
gPrefixMode: false,
|
||||
@@ -83,6 +68,8 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
|
||||
|
||||
setQuery: query => set({ query, error: undefined }),
|
||||
|
||||
setConfig: config => set({ config, error: undefined }),
|
||||
|
||||
setGPrefixMode: enabled => set({ gPrefixMode: enabled }),
|
||||
|
||||
setView: view => set({ view }),
|
||||
@@ -90,10 +77,9 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
|
||||
setSelectedMenuIndex: index => set({ selectedMenuIndex: index }),
|
||||
|
||||
launchSession: async () => {
|
||||
const { query } = get()
|
||||
const parsed = parseQuery(query)
|
||||
const { query, config } = get()
|
||||
|
||||
if (!parsed.query && !parsed.workingDir) {
|
||||
if (!query.trim()) {
|
||||
set({ error: 'Please enter a query to launch a session' })
|
||||
return
|
||||
}
|
||||
@@ -102,8 +88,10 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
|
||||
set({ isLaunching: true, error: undefined })
|
||||
|
||||
const request: LaunchSessionRequest = {
|
||||
query: parsed.query || 'Help me with the current directory',
|
||||
working_dir: parsed.workingDir,
|
||||
query: query.trim(),
|
||||
working_dir: config.workingDir || undefined,
|
||||
model: config.model || undefined,
|
||||
max_turns: config.maxTurns || undefined,
|
||||
}
|
||||
|
||||
const response = await daemonClient.launchSession(request)
|
||||
@@ -139,6 +127,7 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
|
||||
mode: 'command',
|
||||
view: 'menu',
|
||||
query: '',
|
||||
config: { query: '', workingDir: '' },
|
||||
selectedMenuIndex: 0,
|
||||
isLaunching: false,
|
||||
error: undefined,
|
||||
@@ -150,28 +139,17 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
|
||||
export function useSessionLauncherHotkeys() {
|
||||
const { open, close, isOpen, gPrefixMode, setGPrefixMode, createNewSession } = useSessionLauncher()
|
||||
|
||||
// Helper to check if user is typing in an input
|
||||
const isInputFocused = () => {
|
||||
// Helper to check if user is actively typing in a text input
|
||||
const isTypingInInput = () => {
|
||||
const active = document.activeElement
|
||||
if (!active) return false
|
||||
|
||||
// Check for input and textarea elements
|
||||
if (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for contentEditable elements
|
||||
if ((active as HTMLElement).contentEditable === 'true') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if we're inside the command palette modal (more specific check)
|
||||
const commandPalette = document.querySelector('[data-command-palette]')
|
||||
if (commandPalette && commandPalette.contains(active)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
// Only block hotkeys when actively typing in actual input fields
|
||||
return (
|
||||
active.tagName === 'INPUT' ||
|
||||
active.tagName === 'TEXTAREA' ||
|
||||
(active as HTMLElement).contentEditable === 'true'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -188,21 +166,25 @@ export function useSessionLauncherHotkeys() {
|
||||
}
|
||||
|
||||
// C - Create new session directly (bypasses command palette)
|
||||
if (e.key === 'c' && !e.metaKey && !e.ctrlKey && !isInputFocused()) {
|
||||
if (e.key === 'c' && !e.metaKey && !e.ctrlKey && !isTypingInInput()) {
|
||||
e.preventDefault()
|
||||
// Open launcher if not already open
|
||||
if (!isOpen) {
|
||||
open('command')
|
||||
}
|
||||
createNewSession()
|
||||
return
|
||||
}
|
||||
|
||||
// / - Search sessions and approvals (only when not typing)
|
||||
if (e.key === '/' && !e.metaKey && !e.ctrlKey && !isInputFocused()) {
|
||||
if (e.key === '/' && !e.metaKey && !e.ctrlKey && !isTypingInInput()) {
|
||||
e.preventDefault()
|
||||
open('search')
|
||||
return
|
||||
}
|
||||
|
||||
// G prefix navigation (prepare for Phase 2)
|
||||
if (e.key === 'g' && !e.metaKey && !e.ctrlKey && !isInputFocused()) {
|
||||
if (e.key === 'g' && !e.metaKey && !e.ctrlKey && !isTypingInInput()) {
|
||||
e.preventDefault()
|
||||
setGPrefixMode(true)
|
||||
setTimeout(() => setGPrefixMode(false), 2000)
|
||||
|
||||
197
humanlayer-wui/src/lib/fuzzy-search.ts
Normal file
197
humanlayer-wui/src/lib/fuzzy-search.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Fuzzy search utilities inspired by Superhuman and Linear
|
||||
* Provides fast, intuitive search with highlighting and scoring
|
||||
*/
|
||||
|
||||
export interface FuzzyMatch {
|
||||
score: number
|
||||
indices: number[]
|
||||
item: any
|
||||
matches: Array<{ indices: number[]; key?: string }>
|
||||
}
|
||||
|
||||
export interface FuzzySearchOptions {
|
||||
keys?: string[]
|
||||
threshold?: number
|
||||
includeScore?: boolean
|
||||
includeMatches?: boolean
|
||||
minMatchCharLength?: number
|
||||
ignoreLocation?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate fuzzy match score for a single string
|
||||
*/
|
||||
function fuzzyMatchString(pattern: string, text: string): { score: number; indices: number[] } {
|
||||
if (!pattern) return { score: 1, indices: [] }
|
||||
if (!text) return { score: 0, indices: [] }
|
||||
|
||||
pattern = pattern.toLowerCase()
|
||||
text = text.toLowerCase()
|
||||
|
||||
let patternIdx = 0
|
||||
let textIdx = 0
|
||||
const indices: number[] = []
|
||||
let score = 0
|
||||
let consecutiveMatches = 0
|
||||
|
||||
while (patternIdx < pattern.length && textIdx < text.length) {
|
||||
if (pattern[patternIdx] === text[textIdx]) {
|
||||
indices.push(textIdx)
|
||||
consecutiveMatches++
|
||||
|
||||
// Bonus for consecutive matches (like Superhuman)
|
||||
score += consecutiveMatches * 2
|
||||
|
||||
// Bonus for start of word matches
|
||||
if (textIdx === 0 || text[textIdx - 1] === ' ' || text[textIdx - 1] === '/') {
|
||||
score += 5
|
||||
}
|
||||
|
||||
patternIdx++
|
||||
} else {
|
||||
consecutiveMatches = 0
|
||||
}
|
||||
textIdx++
|
||||
}
|
||||
|
||||
// Penalty for unmatched pattern characters
|
||||
if (patternIdx < pattern.length) {
|
||||
score = 0
|
||||
} else {
|
||||
// Bonus for shorter strings (exact matches rank higher)
|
||||
score += Math.max(0, 100 - text.length)
|
||||
|
||||
// Bonus for match density
|
||||
score += (indices.length / text.length) * 50
|
||||
}
|
||||
|
||||
return { score, indices }
|
||||
}
|
||||
|
||||
/**
|
||||
* Search through an array of items with fuzzy matching
|
||||
*/
|
||||
export function fuzzySearch<T>(
|
||||
items: T[],
|
||||
pattern: string,
|
||||
options: FuzzySearchOptions = {}
|
||||
): FuzzyMatch[] {
|
||||
const {
|
||||
keys = [],
|
||||
threshold = 0.1,
|
||||
minMatchCharLength = 1,
|
||||
} = options
|
||||
|
||||
if (!pattern || pattern.length < minMatchCharLength) {
|
||||
return items.map(item => ({
|
||||
score: 1,
|
||||
indices: [],
|
||||
item,
|
||||
matches: [],
|
||||
}))
|
||||
}
|
||||
|
||||
const results: FuzzyMatch[] = []
|
||||
|
||||
for (const item of items) {
|
||||
let bestScore = 0
|
||||
let bestMatches: Array<{ indices: number[]; key?: string }> = []
|
||||
|
||||
if (keys.length === 0) {
|
||||
// Search in the item itself (assume it's a string)
|
||||
const match = fuzzyMatchString(pattern, String(item))
|
||||
if (match.score > threshold) {
|
||||
results.push({
|
||||
score: match.score,
|
||||
indices: match.indices,
|
||||
item,
|
||||
matches: [{ indices: match.indices }],
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Search in specified keys
|
||||
for (const key of keys) {
|
||||
const value = (item as any)[key]
|
||||
if (typeof value === 'string') {
|
||||
const match = fuzzyMatchString(pattern, value)
|
||||
if (match.score > bestScore) {
|
||||
bestScore = match.score
|
||||
bestMatches = [{ indices: match.indices, key }]
|
||||
} else if (match.score === bestScore && match.score > threshold) {
|
||||
bestMatches.push({ indices: match.indices, key })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestScore > threshold) {
|
||||
results.push({
|
||||
score: bestScore,
|
||||
indices: bestMatches[0]?.indices || [],
|
||||
item,
|
||||
matches: bestMatches,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score (descending)
|
||||
return results.sort((a, b) => b.score - a.score)
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight matched characters in text (for rendering)
|
||||
*/
|
||||
export function highlightMatches(text: string, indices: number[]): Array<{ text: string; highlighted: boolean }> {
|
||||
if (!indices.length) {
|
||||
return [{ text, highlighted: false }]
|
||||
}
|
||||
|
||||
const result: Array<{ text: string; highlighted: boolean }> = []
|
||||
let lastIndex = 0
|
||||
|
||||
for (const index of indices) {
|
||||
// Add non-highlighted text before this match
|
||||
if (index > lastIndex) {
|
||||
result.push({
|
||||
text: text.slice(lastIndex, index),
|
||||
highlighted: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Add highlighted character
|
||||
result.push({
|
||||
text: text[index],
|
||||
highlighted: true,
|
||||
})
|
||||
|
||||
lastIndex = index + 1
|
||||
}
|
||||
|
||||
// Add remaining non-highlighted text
|
||||
if (lastIndex < text.length) {
|
||||
result.push({
|
||||
text: text.slice(lastIndex),
|
||||
highlighted: false,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory-specific fuzzy search with path intelligence
|
||||
*/
|
||||
export function fuzzySearchDirectories(directories: string[], pattern: string): FuzzyMatch[] {
|
||||
// Enhance pattern matching for directory paths
|
||||
const enhancedPattern = pattern.replace(/^~/, process.env.HOME || '')
|
||||
|
||||
return fuzzySearch(directories, enhancedPattern, {
|
||||
threshold: 0.1,
|
||||
minMatchCharLength: 1,
|
||||
}).map(match => ({
|
||||
...match,
|
||||
// Boost score for exact directory name matches
|
||||
score: match.item.endsWith('/' + pattern) ? match.score + 100 : match.score,
|
||||
}))
|
||||
}
|
||||
338
prompt.md
Normal file
338
prompt.md
Normal file
@@ -0,0 +1,338 @@
|
||||
# Phase 1: Core Command Palette Implementation
|
||||
|
||||
## Objective
|
||||
|
||||
Build the minimal viable command palette launcher - a full-screen overlay triggered by `Cmd+K` with a single input field that can launch sessions with basic parsing and instant feedback.
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. **SessionLauncher.tsx** - Full-screen command palette overlay
|
||||
2. **CommandInput.tsx** - Smart input with basic parsing
|
||||
3. **useSessionLauncher.ts** - Zustand state management
|
||||
4. **Global hotkey integration** - Cmd+K trigger
|
||||
5. **Basic session launch** - Integration with daemon client
|
||||
|
||||
## Technical Specs
|
||||
|
||||
### SessionLauncher Component
|
||||
|
||||
```typescript
|
||||
interface SessionLauncherProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// Features:
|
||||
// - Full-screen overlay (backdrop blur)
|
||||
// - Centered modal with monospace font
|
||||
// - High contrast design
|
||||
// - Escape key to close
|
||||
// - Click outside to close
|
||||
```
|
||||
|
||||
### CommandInput Component
|
||||
|
||||
```typescript
|
||||
interface CommandInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
// Features:
|
||||
// - Large input field with monospace font
|
||||
// - Enter key to submit
|
||||
// - Real-time character count
|
||||
// - Focus management
|
||||
// - Smooth animations
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
```typescript
|
||||
interface LauncherState {
|
||||
isOpen: boolean
|
||||
mode: 'command' | 'search' // command = launch sessions, search = find sessions/approvals
|
||||
query: string
|
||||
isLaunching: boolean
|
||||
error?: string
|
||||
gPrefixMode: boolean
|
||||
|
||||
// Actions
|
||||
open: (mode?: 'command' | 'search') => void
|
||||
close: () => void
|
||||
setQuery: (query: string) => void
|
||||
setGPrefixMode: (enabled: boolean) => void
|
||||
launchSession: () => Promise<void>
|
||||
reset: () => void
|
||||
}
|
||||
```
|
||||
|
||||
### Basic Query Parsing
|
||||
|
||||
```typescript
|
||||
interface ParsedQuery {
|
||||
query: string // Main text
|
||||
workingDir?: string // If query starts with /path
|
||||
}
|
||||
|
||||
// Parse patterns:
|
||||
// "debug login component" → { query: "debug login component" }
|
||||
// "/src debug login" → { query: "debug login", workingDir: "/src" }
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
humanlayer-wui/src/
|
||||
├── components/
|
||||
│ ├── SessionLauncher.tsx # Main overlay component
|
||||
│ └── CommandInput.tsx # Input field component
|
||||
├── hooks/
|
||||
│ └── useSessionLauncher.ts # State management
|
||||
└── stores/
|
||||
└── sessionStore.ts # Extended zustand store
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create SessionLauncher Component (1 hour)
|
||||
|
||||
```typescript
|
||||
// SessionLauncher.tsx
|
||||
export function SessionLauncher({ isOpen, onClose }: SessionLauncherProps) {
|
||||
// Full-screen overlay with backdrop
|
||||
// Centered modal with session input
|
||||
// Escape key handling
|
||||
// Animation on open/close
|
||||
}
|
||||
```
|
||||
|
||||
**Styling Requirements**:
|
||||
- Full viewport overlay with backdrop-blur
|
||||
- Centered modal (max-width: 600px)
|
||||
- Monospace font family
|
||||
- High contrast colors (dark background, white text)
|
||||
- Smooth fade-in/out animations
|
||||
- Focus trap when open
|
||||
|
||||
### Step 2: Create CommandInput Component (1 hour)
|
||||
|
||||
```typescript
|
||||
// CommandInput.tsx
|
||||
export function CommandInput({ value, onChange, onSubmit }: CommandInputProps) {
|
||||
// Large input field
|
||||
// Enter key handling
|
||||
// Character count display
|
||||
// Loading state
|
||||
}
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Large text input (48px height minimum)
|
||||
- Monospace font
|
||||
- Placeholder text with examples
|
||||
- Real-time character count
|
||||
- Loading spinner when launching
|
||||
- Submit on Enter key
|
||||
|
||||
### Step 3: State Management Hook (1 hour)
|
||||
|
||||
```typescript
|
||||
// useSessionLauncher.ts
|
||||
export const useSessionLauncher = create<LauncherState>((set, get) => ({
|
||||
isOpen: false,
|
||||
query: '',
|
||||
isLaunching: false,
|
||||
|
||||
open: () => set({ isOpen: true }),
|
||||
close: () => set({ isOpen: false, query: '', error: undefined }),
|
||||
setQuery: (query) => set({ query }),
|
||||
|
||||
launchSession: async () => {
|
||||
// Basic session launch logic
|
||||
// Error handling
|
||||
// Success navigation
|
||||
}
|
||||
}))
|
||||
```
|
||||
|
||||
### Step 4: Global Hotkey Integration (45 minutes)
|
||||
|
||||
```typescript
|
||||
// Add to App.tsx or main component
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Cmd+K - Global command palette
|
||||
if (e.metaKey && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
openLauncher('command')
|
||||
}
|
||||
|
||||
// / - Search sessions and approvals
|
||||
if (e.key === '/' && !e.metaKey && !e.ctrlKey && !isInputFocused()) {
|
||||
e.preventDefault()
|
||||
openLauncher('search')
|
||||
}
|
||||
|
||||
// G prefix navigation (prepare for Phase 2)
|
||||
if (e.key === 'g' && !e.metaKey && !e.ctrlKey && !isInputFocused()) {
|
||||
e.preventDefault()
|
||||
setGPrefixMode(true)
|
||||
setTimeout(() => setGPrefixMode(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// Helper to check if user is typing in an input
|
||||
const isInputFocused = () => {
|
||||
const active = document.activeElement
|
||||
return active?.tagName === 'INPUT' || active?.tagName === 'TEXTAREA' || active?.contentEditable === 'true'
|
||||
}
|
||||
```
|
||||
|
||||
**Enhanced Hotkey Features**:
|
||||
- `Cmd+K` - Opens command palette in "command" mode (launch sessions)
|
||||
- `/` - Opens command palette in "search" mode (find sessions/approvals)
|
||||
- `g` prefix - Sets up for vim-style navigation (Phase 2: g+a = approvals, g+s = sessions)
|
||||
- Smart input detection to avoid conflicts when user is typing
|
||||
|
||||
### Step 5: Session Launch Integration (30 minutes)
|
||||
|
||||
```typescript
|
||||
const launchSession = async () => {
|
||||
try {
|
||||
set({ isLaunching: true, error: undefined })
|
||||
|
||||
const parsed = parseQuery(get().query)
|
||||
const response = await daemonClient.launchSession({
|
||||
query: parsed.query,
|
||||
working_dir: parsed.workingDir || process.cwd()
|
||||
})
|
||||
|
||||
// Navigate to new session
|
||||
navigate(`/session/${response.session_id}`)
|
||||
|
||||
// Close launcher
|
||||
get().close()
|
||||
} catch (error) {
|
||||
set({ error: error.message })
|
||||
} finally {
|
||||
set({ isLaunching: false })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## UI Design Requirements
|
||||
|
||||
### Visual Style
|
||||
- **Background**: Full-screen overlay with backdrop-blur-sm
|
||||
- **Modal**: Centered, rounded corners, dark background
|
||||
- **Typography**: Monospace font (ui-monospace, Monaco, "Cascadia Code")
|
||||
- **Colors**: High contrast - white text on dark backgrounds
|
||||
- **Spacing**: Generous padding and margins for breathing room
|
||||
|
||||
### Layout Structure
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ [OVERLAY] │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ > [INPUT FIELD] │ │
|
||||
│ │ │ │
|
||||
│ │ [CHARACTER COUNT] │ │
|
||||
│ │ │ │
|
||||
│ │ ↵ Launch ⌘K Close │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Interaction States
|
||||
- **Default**: Clean input with placeholder
|
||||
- **Typing**: Real-time character count
|
||||
- **Loading**: Spinner + "Launching..." text
|
||||
- **Error**: Red error message below input
|
||||
- **Success**: Quick flash before navigation
|
||||
|
||||
## Integration Points
|
||||
|
||||
### App.tsx Integration
|
||||
```typescript
|
||||
function App() {
|
||||
const { isOpen, close } = useSessionLauncher()
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Existing app content */}
|
||||
<SessionTable />
|
||||
<SessionDetail />
|
||||
|
||||
{/* Command palette overlay */}
|
||||
<SessionLauncher isOpen={isOpen} onClose={close} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### SessionTable Button
|
||||
Add floating action button or header button:
|
||||
```typescript
|
||||
<Button
|
||||
onClick={() => useSessionLauncher.getState().open()}
|
||||
className="fixed bottom-6 right-6"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
```
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Unit Tests
|
||||
- SessionLauncher renders correctly
|
||||
- CommandInput handles input changes
|
||||
- Hotkey triggers launcher open
|
||||
- Session launch calls daemon client
|
||||
- Error states display properly
|
||||
|
||||
### Integration Tests
|
||||
- End-to-end session creation flow
|
||||
- Keyboard navigation works
|
||||
- Mobile responsiveness
|
||||
- Focus management
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. ✅ `Cmd+K` opens full-screen command palette
|
||||
2. ✅ Single input field with monospace font
|
||||
3. ✅ Enter key launches session with daemon client
|
||||
4. ✅ Escape key closes launcher
|
||||
5. ✅ Loading states during session creation
|
||||
6. ✅ Error handling with user feedback
|
||||
7. ✅ Navigation to new session on success
|
||||
8. ✅ Clean, high-contrast design
|
||||
9. ✅ Smooth animations and interactions
|
||||
10. ✅ Mobile responsive layout
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- Launcher opens in <50ms from keypress
|
||||
- Session launches successfully 95% of the time
|
||||
- Error messages are clear and actionable
|
||||
- Interface is intuitive without documentation
|
||||
- Works on desktop and mobile
|
||||
|
||||
## Next Phase Preparation
|
||||
|
||||
This implementation should be designed to easily extend with:
|
||||
- Template parsing (`:debug`, `:review`)
|
||||
- Model selection (`@claude-opus`)
|
||||
- Advanced flags (`--max-turns=5`)
|
||||
- Context detection and suggestions
|
||||
- Recent session history
|
||||
|
||||
Keep the architecture clean and extensible for Phase 2 enhancements.
|
||||
Reference in New Issue
Block a user