mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
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:
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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user