Merge pull request #145 from MervinPraison/claude/issue-128-20260617-0955

feat: add session search palette with Ctrl+K shortcut
This commit is contained in:
Mervin Praison authored and GitHub committed 2026-06-24 09:47:14 +01:00
commit da9ff14884
6 files changed
+348 -2

No files matched your search

@@ -0,0 +1,170 @@
import { useEffect, useState, useCallback } from 'react'
import {
CommandDialog,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
CommandGroup,
} from "@/components/ui/command"
interface Session {
id: string
title: string
created_at: string
updated_at: string
message_count: number
}
interface SessionSearchProps {
open: boolean
onOpenChange: (open: boolean) => void
onSessionSelect: (sessionId: string) => void
currentSessionId?: string | null
}
export function SessionSearch({ open, onOpenChange, onSessionSelect, currentSessionId }: SessionSearchProps) {
const [sessions, setSessions] = useState<Session[]>([])
const [loading, setLoading] = useState(false)
// Fetch sessions when dialog opens
useEffect(() => {
if (!open) return
let active = true
const fetchSessions = async () => {
try {
setLoading(true)
const res = await fetch('/sessions')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (active) {
setSessions(data.sessions || [])
}
} catch (err) {
console.error('Failed to fetch sessions:', err)
if (active) {
setSessions([])
}
} finally {
if (active) {
setLoading(false)
}
}
}
fetchSessions()
return () => {
active = false
}
}, [open])
const handleSelect = useCallback((sessionId: string) => {
onSessionSelect(sessionId)
onOpenChange(false)
}, [onSessionSelect, onOpenChange])
const formatDate = (iso: string) => {
try {
const date = new Date(iso)
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) return 'Today'
if (days === 1) return 'Yesterday'
if (days < 7) return `${days} days ago`
if (days < 30) return `${Math.floor(days / 7)} weeks ago`
if (days < 365) return `${Math.floor(days / 30)} months ago`
return `${Math.floor(days / 365)} years ago`
} catch {
return iso
}
}
// Group sessions by date
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
const lastWeek = new Date(today)
lastWeek.setDate(lastWeek.getDate() - 7)
const todayStr = today.toDateString()
const yesterdayStr = yesterday.toDateString()
const groupedSessions = sessions.reduce((groups, session) => {
const date = new Date(session.updated_at)
const dateStr = date.toDateString()
let group = 'Older'
if (dateStr === todayStr) {
group = 'Today'
} else if (dateStr === yesterdayStr) {
group = 'Yesterday'
} else if (date > lastWeek) {
group = 'This Week'
}
if (!groups[group]) groups[group] = []
groups[group].push(session)
return groups
}, {} as Record<string, Session[]>)
const groupOrder = ['Today', 'Yesterday', 'This Week', 'Older']
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title="Search Sessions"
description="Search for a session by title or ID"
>
<CommandInput
placeholder="Search sessions..."
disabled={loading}
/>
<CommandList>
{loading ? (
<div className="py-6 text-center text-sm text-muted-foreground">Loading sessions...</div>
) : sessions.length === 0 ? (
<CommandEmpty>No sessions found. Start a chat to create one.</CommandEmpty>
) : (
<>
<CommandEmpty>No sessions matching your search.</CommandEmpty>
{groupOrder.map((group) => {
const groupSessions = groupedSessions[group]
if (!groupSessions || groupSessions.length === 0) return null
return (
<CommandGroup key={group} heading={group}>
{groupSessions.map((session) => (
<CommandItem
key={session.id}
value={`${session.title} ${session.id}`}
onSelect={() => handleSelect(session.id)}
className="flex items-center justify-between"
>
<div className="flex flex-col">
<span className="font-medium">
{session.title || 'Untitled Session'}
</span>
<span className="text-xs text-muted-foreground">
{session.message_count} messages · {formatDate(session.updated_at)}
</span>
</div>
{currentSessionId === session.id && (
<span className="text-xs px-2 py-0.5 rounded bg-primary text-primary-foreground">
Current
</span>
)}
</CommandItem>
))}
</CommandGroup>
)
})}
</>
)}
</CommandList>
</CommandDialog>
)
}
@@ -0,0 +1,30 @@
import { useEffect, useState, useCallback } from 'react'
export function useSessionSearch(enabled: boolean = true) {
const [open, setOpen] = useState(false)
useEffect(() => {
if (!enabled) return
const handleKeyDown = (e: KeyboardEvent) => {
// Check for Ctrl+K (Windows/Linux) or Cmd+K (Mac)
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setOpen((prev) => !prev)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [enabled])
const onOpenChange = useCallback((open: boolean) => {
setOpen(open)
}, [])
return {
open,
setOpen,
onOpenChange,
}
}
+19 -1
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useState } from 'react'
import type { ChatConfig, ChatProfile } from '../types'
import { ChatArea, SessionManager } from '../chat'
import { SessionSearch } from '../components/SessionSearch'
import { useSessionSearch } from '../hooks/useSessionSearch'
interface AgentUILayoutProps {
config?: ChatConfig
@@ -13,6 +15,9 @@ export function AgentUILayout({ config, title }: AgentUILayoutProps) {
const [selectedProfile, setSelectedProfile] = useState<ChatProfile | undefined>(defaultProfile)
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'agents' | 'sessions'>('agents')
// Session search palette (Ctrl+K)
const { open: sessionSearchOpen, onOpenChange: onSessionSearchChange } = useSessionSearch()
// Fetch profiles dynamically from the API (same as ProfilePicker in ChatLayout)
useEffect(() => {
@@ -42,6 +47,14 @@ export function AgentUILayout({ config, title }: AgentUILayoutProps) {
return (
<div className="flex h-screen bg-background">
{/* Session search palette */}
<SessionSearch
open={sessionSearchOpen}
onOpenChange={onSessionSearchChange}
onSessionSelect={handleSessionSelect}
currentSessionId={currentSessionId}
/>
{/* Sidebar */}
<aside className="w-64 border-r flex flex-col">
<header className="border-b px-4 py-3">
@@ -142,7 +155,12 @@ export function AgentUILayout({ config, title }: AgentUILayoutProps) {
</header>
)}
<div className="flex-1 overflow-hidden">
<ChatArea config={config} className="h-full" />
<ChatArea
config={config}
className="h-full"
sessionId={currentSessionId}
onSessionChange={handleSessionSelect}
/>
</div>
</main>
</div>
+13
View File
@@ -2,6 +2,8 @@ import { useCallback, useState } from 'react'
import type { ChatConfig, LayoutConfig } from '../types'
import { ChatArea, SessionManager } from '../chat'
import { ProfilePicker } from '../chat/ProfilePicker'
import { SessionSearch } from '../components/SessionSearch'
import { useSessionSearch } from '../hooks/useSessionSearch'
interface ChatLayoutProps {
config?: ChatConfig
@@ -14,6 +16,9 @@ export function ChatLayout({ config, layout, title }: ChatLayoutProps) {
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
const [showSessions, setShowSessions] = useState(true)
const [sessionListKey, setSessionListKey] = useState(0)
// Session search palette (Ctrl+K)
const { open: sessionSearchOpen, onOpenChange: onSessionSearchChange } = useSessionSearch(mode === 'fullscreen')
const handleSessionSelect = useCallback((sessionId: string) => {
setCurrentSessionId(sessionId)
@@ -33,6 +38,14 @@ export function ChatLayout({ config, layout, title }: ChatLayoutProps) {
if (mode === 'fullscreen') {
return (
<div className="flex h-screen bg-background">
{/* Session search palette */}
<SessionSearch
open={sessionSearchOpen}
onOpenChange={onSessionSearchChange}
onSessionSelect={handleSessionSelect}
currentSessionId={currentSessionId}
/>
{/* Session sidebar */}
{showSessions && config?.features?.history !== false && (
<aside className="w-64 border-r flex-shrink-0">
+18 -1
View File
@@ -4,6 +4,8 @@ import type { DashboardPageDef, DashboardTabGroup } from '../types/dashboard'
import { ChatArea, SessionManager } from '../chat'
import { ProfilePicker } from '../chat/ProfilePicker'
import { resolveView } from '../views'
import { SessionSearch } from '../components/SessionSearch'
import { useSessionSearch } from '../hooks/useSessionSearch'
interface DashboardLayoutProps {
config?: ChatConfig
@@ -39,6 +41,9 @@ export function DashboardLayout({ config, layout: _layout, title, logo }: Dashbo
// Protocol-driven: pages fetched from /api/pages
const [pages, setPages] = useState<DashboardPageDef[]>([])
const [tabGroups, setTabGroups] = useState<DashboardTabGroup[]>([])
// Session search palette (Ctrl+K)
const { open: sessionSearchOpen, onOpenChange: onSessionSearchChange } = useSessionSearch()
// Fetch registered pages from the backend protocol endpoint
useEffect(() => {
@@ -60,7 +65,11 @@ export function DashboardLayout({ config, layout: _layout, title, logo }: Dashbo
const handleSessionSelect = useCallback((sessionId: string) => {
setCurrentSessionId(sessionId)
}, [])
// If selecting a session, ensure we're on the chat tab
if (activeTab !== 'chat') {
setActiveTab('chat')
}
}, [activeTab])
const handleNewSession = useCallback(() => {
setCurrentSessionId(null)
@@ -124,6 +133,14 @@ export function DashboardLayout({ config, layout: _layout, title, logo }: Dashbo
return (
<div className="flex h-screen bg-background text-foreground">
{/* Session search palette */}
<SessionSearch
open={sessionSearchOpen}
onOpenChange={onSessionSearchChange}
onSessionSelect={handleSessionSelect}
currentSessionId={currentSessionId}
/>
{/* Sidebar navigation */}
<aside
className={`${navCollapsed ? 'w-14' : 'w-52'} flex-shrink-0 border-r bg-card flex flex-col transition-all duration-200`}
+98
View File
@@ -0,0 +1,98 @@
"""Unit tests for session search functionality."""
from unittest.mock import AsyncMock, patch
import pytest
from starlette.testclient import TestClient
def test_sessions_endpoint_returns_list(test_client):
"""Test that /sessions endpoint returns a list of sessions."""
# Mock the session manager to return test sessions
with patch("praisonaiui.server._datastore") as mock_datastore:
mock_datastore.list_sessions = AsyncMock(return_value=[
{
"id": "session-1",
"title": "Test Session 1",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T01:00:00Z",
"message_count": 5,
},
{
"id": "session-2",
"title": "Test Session 2",
"created_at": "2024-01-02T00:00:00Z",
"updated_at": "2024-01-02T01:00:00Z",
"message_count": 10,
},
])
response = test_client.get("/sessions")
assert response.status_code == 200
data = response.json()
assert "sessions" in data
assert len(data["sessions"]) == 2
assert data["sessions"][0]["id"] == "session-1"
assert data["sessions"][1]["id"] == "session-2"
def test_sessions_endpoint_empty_list(test_client):
"""Test that /sessions endpoint returns empty list when no sessions."""
with patch("praisonaiui.server._datastore") as mock_datastore:
mock_datastore.list_sessions = AsyncMock(return_value=[])
response = test_client.get("/sessions")
assert response.status_code == 200
data = response.json()
assert "sessions" in data
assert data["sessions"] == []
def test_session_search_filters_by_title():
"""Test that session search can filter by title (client-side simulation)."""
sessions = [
{"id": "1", "title": "Chat about Python", "message_count": 5},
{"id": "2", "title": "JavaScript discussion", "message_count": 3},
{"id": "3", "title": "Python tutorial", "message_count": 8},
]
# Simulate client-side filtering by title
search_term = "python"
filtered = [
s for s in sessions
if search_term.lower() in s["title"].lower()
]
assert len(filtered) == 2
assert filtered[0]["id"] == "1"
assert filtered[1]["id"] == "3"
def test_session_search_filters_by_id():
"""Test that session search can filter by ID (client-side simulation)."""
sessions = [
{"id": "abc-123", "title": "Session One", "message_count": 5},
{"id": "def-456", "title": "Session Two", "message_count": 3},
{"id": "abc-789", "title": "Session Three", "message_count": 8},
]
# Simulate client-side filtering by ID
search_term = "abc"
filtered = [
s for s in sessions
if search_term.lower() in s["id"].lower()
]
assert len(filtered) == 2
assert filtered[0]["id"] == "abc-123"
assert filtered[1]["id"] == "abc-789"
@pytest.fixture
def test_client():
"""Create a test client for the app."""
from praisonaiui.server import create_app
return TestClient(create_app())