mirror of
https://github.com/humanlayer/humanlayer.git
synced 2025-08-20 19:01:22 +03:00
Merge pull request #438 from dexhorthy/dexter/eng-1941-add-brainrot-mode-easter-egg-and-improve-command-palette-ux
feat: improve command palette UX
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useSessionLauncher } from '@/hooks/useSessionLauncher'
|
||||
import { useSessionLauncher, isViewingSessionDetail } from '@/hooks/useSessionLauncher'
|
||||
import { useStore } from '@/AppStore'
|
||||
import { highlightMatches, type FuzzyMatch } from '@/lib/fuzzy-search'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSessionFilter } from '@/hooks/useSessionFilter'
|
||||
import { EmptyState } from './internal/EmptyState'
|
||||
import { Search } from 'lucide-react'
|
||||
import { KeyboardShortcut } from './HotkeyPanel'
|
||||
|
||||
interface MenuOption {
|
||||
id: string
|
||||
@@ -14,17 +15,23 @@ interface MenuOption {
|
||||
description?: string
|
||||
action: () => void
|
||||
sessionId?: string
|
||||
hotkey?: string
|
||||
}
|
||||
|
||||
export default function CommandPaletteMenu() {
|
||||
const { createNewSession, openSessionById, selectedMenuIndex, setSelectedMenuIndex, mode } =
|
||||
const { createNewSession, openSessionById, selectedMenuIndex, setSelectedMenuIndex, mode, close } =
|
||||
useSessionLauncher()
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Get sessions from the main app store
|
||||
// Get sessions and state from the main app store
|
||||
const sessions = useStore(state => state.sessions)
|
||||
const focusedSession = useStore(state => state.focusedSession)
|
||||
const selectedSessions = useStore(state => state.selectedSessions)
|
||||
const activeSessionDetail = useStore(state => state.activeSessionDetail)
|
||||
const archiveSession = useStore(state => state.archiveSession)
|
||||
const bulkArchiveSessions = useStore(state => state.bulkArchiveSessions)
|
||||
|
||||
// Use the shared session filter hook
|
||||
const { filteredSessions, statusFilter, searchText, matchedSessions } = useSessionFilter({
|
||||
@@ -33,14 +40,86 @@ export default function CommandPaletteMenu() {
|
||||
searchFields: ['summary', 'model'], // Search in both summary and model fields for the modal
|
||||
})
|
||||
|
||||
// Check if we're viewing a session detail
|
||||
const isSessionDetail = isViewingSessionDetail()
|
||||
|
||||
// Check if we should show archive option
|
||||
const isSessionTable = !isSessionDetail && window.location.hash === '#/'
|
||||
const shouldShowArchive =
|
||||
isSessionDetail || (isSessionTable && (focusedSession || selectedSessions.size > 0))
|
||||
|
||||
// Determine if we should show unarchive instead of archive
|
||||
const getArchiveLabel = (): string => {
|
||||
if (isSessionDetail && activeSessionDetail) {
|
||||
return activeSessionDetail.session.archived ? 'Unarchive' : 'Archive'
|
||||
} else if (selectedSessions.size > 0) {
|
||||
// For bulk operations, check if all selected sessions have same archive state
|
||||
const sessionIds = Array.from(selectedSessions)
|
||||
const sessionsToCheck = sessions.filter(s => sessionIds.includes(s.id))
|
||||
const allArchived = sessionsToCheck.every(s => s.archived)
|
||||
const allActive = sessionsToCheck.every(s => !s.archived)
|
||||
|
||||
// If mixed state, use "Archive" as default
|
||||
if (!allArchived && !allActive) {
|
||||
return 'Archive'
|
||||
}
|
||||
return allArchived ? 'Unarchive' : 'Archive'
|
||||
} else if (focusedSession) {
|
||||
return focusedSession.archived ? 'Unarchive' : 'Archive'
|
||||
}
|
||||
return 'Archive' // Default
|
||||
}
|
||||
|
||||
// Build base menu options
|
||||
const baseOptions: MenuOption[] = [
|
||||
{
|
||||
id: 'create-session',
|
||||
label: 'Create Session',
|
||||
description: 'Start a new session with AI assistance',
|
||||
action: createNewSession,
|
||||
hotkey: 'C',
|
||||
},
|
||||
...(isSessionDetail && searchQuery.toLowerCase().includes('brain')
|
||||
? [
|
||||
{
|
||||
id: 'toggle-brainrot',
|
||||
label: 'Brainrot Mode',
|
||||
action: () => {
|
||||
window.dispatchEvent(new CustomEvent('toggle-brainrot-mode'))
|
||||
close()
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(shouldShowArchive
|
||||
? [
|
||||
{
|
||||
id: 'archive-session',
|
||||
label: getArchiveLabel(),
|
||||
action: async () => {
|
||||
if (isSessionDetail && activeSessionDetail) {
|
||||
// Archive current session in detail view
|
||||
await archiveSession(
|
||||
activeSessionDetail.session.id,
|
||||
!activeSessionDetail.session.archived,
|
||||
)
|
||||
close()
|
||||
} else if (selectedSessions.size > 0) {
|
||||
// Bulk archive selected sessions
|
||||
const sessionIds = Array.from(selectedSessions)
|
||||
const sessionsToArchive = sessions.filter(s => sessionIds.includes(s.id))
|
||||
const allArchived = sessionsToArchive.every(s => s.archived)
|
||||
await bulkArchiveSessions(sessionIds, !allArchived)
|
||||
close()
|
||||
} else if (focusedSession) {
|
||||
// Archive focused session
|
||||
await archiveSession(focusedSession.id, !focusedSession.archived)
|
||||
close()
|
||||
}
|
||||
},
|
||||
hotkey: 'E',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
// Command mode: Only Create Session
|
||||
@@ -51,21 +130,25 @@ export default function CommandPaletteMenu() {
|
||||
id: `open-${session.id}`,
|
||||
label:
|
||||
session.summary || `${session.query.slice(0, 40)}${session.query.length > 40 ? '...' : ''}`,
|
||||
description: `${session.status} • ${session.model || 'Unknown model'}`,
|
||||
action: () => openSessionById(session.id),
|
||||
sessionId: session.id, // Store for match lookup
|
||||
}))
|
||||
: [] // No sessions in command mode
|
||||
|
||||
// Filter options based on search query in command mode
|
||||
const filteredBaseOptions = searchQuery
|
||||
? baseOptions.filter(option => option.label.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: baseOptions
|
||||
|
||||
// Combine options based on mode
|
||||
const menuOptions: MenuOption[] =
|
||||
mode === 'command'
|
||||
? baseOptions // Command: Only Create Session
|
||||
? filteredBaseOptions // Command: Filtered base options
|
||||
: sessionOptions // Search: Only sessions (no Create Session), already limited to 5
|
||||
|
||||
// Keyboard navigation
|
||||
// Keyboard navigation - only arrow keys
|
||||
useHotkeys(
|
||||
'up, k',
|
||||
'up',
|
||||
() => {
|
||||
setSelectedMenuIndex(selectedMenuIndex > 0 ? selectedMenuIndex - 1 : menuOptions.length - 1)
|
||||
},
|
||||
@@ -73,7 +156,7 @@ export default function CommandPaletteMenu() {
|
||||
)
|
||||
|
||||
useHotkeys(
|
||||
'down, j',
|
||||
'down',
|
||||
() => {
|
||||
setSelectedMenuIndex(selectedMenuIndex < menuOptions.length - 1 ? selectedMenuIndex + 1 : 0)
|
||||
},
|
||||
@@ -117,8 +200,8 @@ export default function CommandPaletteMenu() {
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Search input for search mode */}
|
||||
{mode === 'search' && (
|
||||
{/* Search input for both modes */}
|
||||
{(mode === 'search' || mode === 'command') && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
@@ -135,7 +218,7 @@ export default function CommandPaletteMenu() {
|
||||
menuOptions[selectedMenuIndex].action()
|
||||
}
|
||||
}}
|
||||
placeholder="Search sessions..."
|
||||
placeholder={mode === 'search' ? 'Search sessions...' : 'Search commands...'}
|
||||
className={cn(
|
||||
'w-full h-9 px-3 py-2 text-sm',
|
||||
'font-mono',
|
||||
@@ -175,7 +258,7 @@ export default function CommandPaletteMenu() {
|
||||
<div
|
||||
key={option.id}
|
||||
className={cn(
|
||||
'p-2 rounded cursor-pointer transition-all duration-150',
|
||||
'p-3 rounded cursor-pointer transition-all duration-150',
|
||||
index === selectedMenuIndex
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted/30 hover:bg-muted/60',
|
||||
@@ -186,23 +269,14 @@ export default function CommandPaletteMenu() {
|
||||
}}
|
||||
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',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{matchData
|
||||
? renderHighlightedText(option.description, matchData.matches, 'description')
|
||||
: option.description}
|
||||
? renderHighlightedText(option.label, matchData.matches, 'label')
|
||||
: option.label}
|
||||
</div>
|
||||
)}
|
||||
{option.hotkey && <KeyboardShortcut keyString={option.hotkey} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -217,7 +291,7 @@ export default function CommandPaletteMenu() {
|
||||
|
||||
<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>↑↓ j/k Navigate</span>
|
||||
<span>↑↓ Navigate</span>
|
||||
<span>↵ Select</span>
|
||||
</div>
|
||||
<span>ESC Close</span>
|
||||
|
||||
162
humanlayer-wui/src/components/DvdScreensaver.tsx
Normal file
162
humanlayer-wui/src/components/DvdScreensaver.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useStore } from '@/AppStore'
|
||||
import { isViewingSessionDetail } from '@/hooks/useSessionLauncher'
|
||||
|
||||
interface Position {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
}
|
||||
|
||||
const themeColors = [
|
||||
'var(--terminal-accent)',
|
||||
'var(--terminal-accent-alt)',
|
||||
'var(--terminal-success)',
|
||||
'var(--terminal-error)',
|
||||
'var(--terminal-warning)',
|
||||
]
|
||||
|
||||
export function DvdScreensaver() {
|
||||
const { activeSessionDetail } = useStore()
|
||||
const [position, setPosition] = useState<Position>({ x: 100, y: 100, vx: 2, vy: 2 })
|
||||
const [isEnabled, setIsEnabled] = useState(false)
|
||||
const [colorIndex, setColorIndex] = useState(0)
|
||||
const animationFrameRef = useRef<number>()
|
||||
const boxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Load saved state from localStorage for current session
|
||||
useEffect(() => {
|
||||
if (activeSessionDetail?.session?.id) {
|
||||
const saved = localStorage.getItem(`brainrot-mode-${activeSessionDetail.session.id}`)
|
||||
if (saved === 'true' && isViewingSessionDetail()) {
|
||||
setIsEnabled(true)
|
||||
} else {
|
||||
setIsEnabled(false)
|
||||
}
|
||||
}
|
||||
}, [activeSessionDetail?.session?.id])
|
||||
|
||||
// Get the most recent tool name
|
||||
const getLatestToolName = (): string => {
|
||||
if (!activeSessionDetail?.conversation) return 'Assistant'
|
||||
|
||||
const events = activeSessionDetail.conversation
|
||||
const lastRelevantEvent = [...events]
|
||||
.reverse()
|
||||
.find(
|
||||
e =>
|
||||
e.eventType === 'tool_call' ||
|
||||
(e.eventType === 'message' && e.role === 'assistant') ||
|
||||
e.eventType === 'thinking',
|
||||
)
|
||||
|
||||
if (lastRelevantEvent?.eventType === 'tool_call') {
|
||||
return lastRelevantEvent.toolName || 'Tool'
|
||||
} else if (lastRelevantEvent?.eventType === 'thinking') {
|
||||
return 'Thinking'
|
||||
}
|
||||
return 'Assistant'
|
||||
}
|
||||
|
||||
// Listen for toggle event
|
||||
useEffect(() => {
|
||||
const handleToggle = () => {
|
||||
setIsEnabled(prev => {
|
||||
const newValue = !prev
|
||||
if (activeSessionDetail?.session?.id) {
|
||||
localStorage.setItem(`brainrot-mode-${activeSessionDetail.session.id}`, String(newValue))
|
||||
}
|
||||
return newValue
|
||||
})
|
||||
}
|
||||
window.addEventListener('toggle-brainrot-mode', handleToggle)
|
||||
return () => window.removeEventListener('toggle-brainrot-mode', handleToggle)
|
||||
}, [activeSessionDetail?.session?.id])
|
||||
|
||||
// Disable when leaving session detail page
|
||||
useEffect(() => {
|
||||
const checkLocation = () => {
|
||||
if (!isViewingSessionDetail()) {
|
||||
setIsEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Check on hash change (navigation)
|
||||
window.addEventListener('hashchange', checkLocation)
|
||||
return () => window.removeEventListener('hashchange', checkLocation)
|
||||
}, [])
|
||||
|
||||
// Animation loop
|
||||
useEffect(() => {
|
||||
if (!isEnabled || !boxRef.current) return
|
||||
|
||||
const animate = () => {
|
||||
const box = boxRef.current
|
||||
if (!box) return
|
||||
|
||||
const rect = box.getBoundingClientRect()
|
||||
const windowWidth = window.innerWidth
|
||||
const windowHeight = window.innerHeight
|
||||
|
||||
setPosition(prev => {
|
||||
let { x, y, vx, vy } = prev
|
||||
|
||||
// Update position
|
||||
x += vx
|
||||
y += vy
|
||||
|
||||
// Bounce off walls and change color
|
||||
let bounced = false
|
||||
if (x <= 0 || x + rect.width >= windowWidth) {
|
||||
vx = -vx
|
||||
x = x <= 0 ? 0 : windowWidth - rect.width
|
||||
bounced = true
|
||||
}
|
||||
if (y <= 0 || y + rect.height >= windowHeight) {
|
||||
vy = -vy
|
||||
y = y <= 0 ? 0 : windowHeight - rect.height
|
||||
bounced = true
|
||||
}
|
||||
|
||||
// Change color on bounce
|
||||
if (bounced) {
|
||||
setColorIndex(prevIndex => (prevIndex + 1) % themeColors.length)
|
||||
}
|
||||
|
||||
return { x, y, vx, vy }
|
||||
})
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
}
|
||||
}, [isEnabled])
|
||||
|
||||
// Only show if enabled, viewing a session detail, and have session data
|
||||
if (!isEnabled || !isViewingSessionDetail() || !activeSessionDetail) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={boxRef}
|
||||
className="fixed z-40 pointer-events-none rounded-md text-xs font-mono flex items-center justify-center overflow-hidden"
|
||||
style={{
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '100px',
|
||||
height: '100px',
|
||||
transition: 'none',
|
||||
backgroundColor: themeColors[colorIndex],
|
||||
color: 'var(--terminal-bg)',
|
||||
}}
|
||||
>
|
||||
<span className="px-2 text-center">{getLatestToolName()}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import '@/App.css'
|
||||
import { logger } from '@/lib/logging'
|
||||
import { DangerousSkipPermissionsMonitor } from '@/components/DangerousSkipPermissionsMonitor'
|
||||
import { KeyboardShortcut } from '@/components/HotkeyPanel'
|
||||
import { DvdScreensaver } from '@/components/DvdScreensaver'
|
||||
|
||||
export function Layout() {
|
||||
const [approvals, setApprovals] = useState<any[]>([])
|
||||
@@ -511,6 +512,9 @@ export function Layout() {
|
||||
|
||||
{/* Global Dangerous Skip Permissions Monitor */}
|
||||
<DangerousSkipPermissionsMonitor />
|
||||
|
||||
{/* DVD Screensaver */}
|
||||
<DvdScreensaver />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,11 @@ interface LauncherState {
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const isViewingSessionDetail = (): boolean => {
|
||||
const hash = window.location.hash
|
||||
return /^#\/sessions\/[^/]+$/.test(hash)
|
||||
}
|
||||
|
||||
const LAST_WORKING_DIR_KEY = 'humanlayer-last-working-dir'
|
||||
const SESSION_LAUNCHER_QUERY_KEY = 'session-launcher-query'
|
||||
|
||||
@@ -219,6 +224,9 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Export helper function
|
||||
export { isViewingSessionDetail }
|
||||
|
||||
// Helper hook for global hotkey management
|
||||
export function useSessionLauncherHotkeys() {
|
||||
const { activeScopes } = useHotkeysContext()
|
||||
|
||||
Reference in New Issue
Block a user