Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const KnowledgeSourceFormPageWrapper = lazy(() => import('./pages/KnowledgeSourc
const PreviewViewPage = lazy(() => import('./pages/PreviewViewPage'));
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
const DataRecordViewWrapper = lazy(() => import('./pages/DataRecordViewWrapper'));
const HubSimplePage = lazy(() => import('./pages/HubSimplePage'));

import { useEffect } from 'react';
import { createFrappeSocket } from './utils/socket';
Expand Down Expand Up @@ -117,6 +118,16 @@ function App() {
<Routes>
<Route
path="/"
element={
<ProtectedRoute>
<Suspense fallback={<PageLoader />}>
<HubSimplePage />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/dashboard"
element={
<ProtectedRoute>
<UnifiedLayout headerActions={<HomeHeaderActions />}>
Expand Down
44 changes: 25 additions & 19 deletions frontend/src/components/HomeHeaderActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Plus, Bot, Workflow } from 'lucide-react';
import { ChevronLeft, Plus, Bot, Workflow } from 'lucide-react';
import { useNavigate } from 'react-router-dom';

export function HomeHeaderActions() {
Expand All @@ -20,23 +20,29 @@ export function HomeHeaderActions() {
};

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm">
<Plus className="w-4 h-4 mr-2" />
New
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleNewFlow}>
<Workflow className="w-4 h-4 mr-2" />
New Flow
</DropdownMenuItem>
<DropdownMenuItem onClick={handleNewAgent}>
<Bot className="w-4 h-4 mr-2" />
New Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => navigate('/')} className="gap-1.5 text-slate-600">
<ChevronLeft className="w-4 h-4" />
Hub
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm">
<Plus className="w-4 h-4 mr-2" />
New
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleNewFlow}>
<Workflow className="w-4 h-4 mr-2" />
New Flow
</DropdownMenuItem>
<DropdownMenuItem onClick={handleNewAgent}>
<Bot className="w-4 h-4 mr-2" />
New Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
138 changes: 138 additions & 0 deletions frontend/src/components/hub/HubConversationView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { useRef, useEffect } from 'react';
import { motion } from 'motion/react';
import { Send, Sparkles, Plus } from 'lucide-react';
import { useUser } from '@/contexts/UserContext';
import { SlashCommandMenu } from './SlashCommandMenu';

interface Message {
role: 'user' | 'assistant';
content: string;
}

interface HubConversationViewProps {
messages: Message[];
inputValue: string;
setInputValue: (v: string) => void;
onSend: () => void;
showSlashMenu: boolean;
slashQuery: string;
onSlashSelect: (cmd: string) => void;
onNewChat: () => void;
isStreaming?: boolean;
}

export function HubConversationView({
messages, inputValue, setInputValue, onSend,
showSlashMenu, slashQuery, onSlashSelect, onNewChat,
isStreaming,
}: HubConversationViewProps) {
const { user } = useUser();
const scrollRef = useRef<HTMLDivElement>(null);

const initials = (user?.full_name || user?.name || 'U')
.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2);

useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages]);

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && !showSlashMenu) {
e.preventDefault();
onSend();
}
};

return (
<div className="flex-1 flex flex-col h-full">
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-6 space-y-4">
{messages.map((msg, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04 }}
className={`flex gap-3 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}
>
<div className="flex-shrink-0">
{msg.role === 'user' ? (
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-violet-500 to-purple-500 flex items-center justify-center text-white text-xs font-medium">
{initials}
</div>
) : (
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-violet-600 to-purple-600 flex items-center justify-center">
<Sparkles className="w-3.5 h-3.5 text-white" />
</div>
)}
</div>
<div className={`flex-1 ${msg.role === 'user' ? 'text-right' : ''}`}>
{msg.role === 'assistant' && (
<div className="flex items-center gap-1.5 mb-1">
<span className="text-xs font-medium text-violet-700">Hub Orchestrator</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-50 text-violet-600 border border-violet-100">System</span>
</div>
)}
{msg.content === '__NO_PROVIDER__' ? (
<div className="inline-block max-w-[85%] px-4 py-3 rounded-xl bg-amber-50 border border-amber-200 text-left">
<p className="text-sm font-medium text-amber-800 mb-1">No AI Provider configured</p>
<p className="text-xs text-amber-700 mb-3">Add a provider and model to start using Hub Orchestrator.</p>
<a href="/huf/models" className="text-xs px-3 py-1.5 rounded-lg bg-violet-600 text-white hover:bg-violet-700 transition-colors inline-block">
Add Provider →
</a>
</div>
) : (
<div className={`inline-block max-w-[85%] px-3 py-2 rounded-xl text-sm text-left ${
msg.role === 'user' ? 'bg-violet-600 text-white' : 'bg-slate-100 text-slate-700'
}`}>
{msg.content}
</div>
)}
</div>
</motion.div>
))}

{/* Typing indicator */}
{(messages.length > 0 && messages[messages.length - 1].role === 'user') || isStreaming ? (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex gap-3">
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-violet-600 to-purple-600 flex items-center justify-center">
<Sparkles className="w-3.5 h-3.5 text-white" />
</div>
<div className="flex items-center gap-1 px-3 py-2 bg-slate-100 rounded-xl">
{[0, 0.15, 0.3].map((delay, i) => (
<motion.div key={i} animate={{ scale: [1, 1.2, 1] }} transition={{ duration: 0.6, repeat: Infinity, delay }} className="w-1.5 h-1.5 rounded-full bg-violet-400" />
))}
</div>
</motion.div>
) : null}
</div>

{/* Input */}
<div className="px-4 py-4 border-t border-slate-100 bg-white">
<div className="max-w-2xl mx-auto relative">
<SlashCommandMenu isVisible={showSlashMenu} query={slashQuery} onSelect={onSlashSelect} />
<div className={`relative bg-white border transition-all rounded-xl ${
showSlashMenu ? 'border-violet-400' : 'border-slate-200 shadow-sm hover:border-slate-300 focus-within:border-violet-400'
}`}>
<textarea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Continue the conversation..."
rows={1}
className="w-full px-4 py-3 pr-24 text-sm resize-none outline-none bg-transparent text-slate-700 placeholder:text-slate-400 min-h-[52px]"
/>
<div className="absolute right-2 bottom-2 flex items-center gap-1">
<button onClick={onNewChat} className="p-1.5 rounded-md text-slate-400 hover:text-violet-600 hover:bg-violet-50 transition-colors" title="New chat">
<Plus className="w-4 h-4" />
</button>
<button onClick={onSend} disabled={!inputValue.trim()} className="p-1.5 rounded-md bg-violet-600 text-white disabled:bg-slate-200 disabled:text-slate-400 hover:bg-violet-700 transition-colors">
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</div>
);
}
96 changes: 96 additions & 0 deletions frontend/src/components/hub/SlashCommandMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { Workflow, Bot, Users, Database, DollarSign, BookOpen, Settings, ArrowRight } from 'lucide-react';

interface Command {
id: string;
label: string;
description: string;
icon: React.ElementType;
}

const COMMANDS: Command[] = [
{ id: '/flow', label: 'Flow', description: 'Create, edit, or manage workflows', icon: Workflow },
{ id: '/agent', label: 'Agent', description: 'Create, configure, or run agents', icon: Bot },
{ id: '/users', label: 'Users', description: 'Manage users and permissions', icon: Users },
{ id: '/runs', label: 'Executions', description: 'View and manage agent runs', icon: Database },
{ id: '/cost', label: 'Cost', description: 'View costs and optimize spending', icon: DollarSign },
{ id: '/knowledge', label: 'Knowledge', description: 'Index and search knowledge sources', icon: BookOpen },
{ id: '/settings', label: 'Settings', description: 'Configure providers and preferences', icon: Settings },
];

interface SlashCommandMenuProps {
isVisible: boolean;
query: string;
onSelect: (command: string) => void;
}

export function SlashCommandMenu({ isVisible, query, onSelect }: SlashCommandMenuProps) {
const [selectedIndex, setSelectedIndex] = useState(0);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);

const filtered = query
? COMMANDS.filter(c => c.id.includes(query.toLowerCase()) || c.label.toLowerCase().includes(query.toLowerCase()))
: COMMANDS;

useEffect(() => { setSelectedIndex(0); }, [query]);

useEffect(() => {
if (!isVisible) return;
const handle = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => (i + 1) % filtered.length); }
if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => (i - 1 + filtered.length) % filtered.length); }
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); if (filtered[selectedIndex]) onSelect(filtered[selectedIndex].id); }
};
window.addEventListener('keydown', handle);
return () => window.removeEventListener('keydown', handle);
}, [isVisible, filtered, selectedIndex, onSelect]);

useEffect(() => {
itemRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);

if (!isVisible || filtered.length === 0) return null;

return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="absolute left-0 right-0 bottom-full mb-1 bg-white rounded-xl border border-violet-300 shadow-xl overflow-hidden z-50"
style={{ maxHeight: 320 }}
>
<div className="overflow-y-auto py-1" style={{ maxHeight: 280 }}>
{filtered.map((cmd, i) => (
<button
key={cmd.id}
ref={el => { itemRefs.current[i] = el; }}
onClick={() => onSelect(cmd.id)}
onMouseEnter={() => setSelectedIndex(i)}
className={`w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors ${i === selectedIndex ? 'bg-violet-50' : 'hover:bg-slate-50'}`}
>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${i === selectedIndex ? 'bg-violet-100 text-violet-600' : 'bg-slate-100 text-slate-500'}`}>
<cmd.icon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<code className={`text-sm font-mono font-medium ${i === selectedIndex ? 'text-violet-700' : 'text-slate-700'}`}>{cmd.id}</code>
<span className="text-xs text-slate-400">—</span>
<span className="text-sm text-slate-600">{cmd.label}</span>
</div>
<p className="text-xs text-slate-500 truncate">{cmd.description}</p>
</div>
{i === selectedIndex && <ArrowRight className="w-4 h-4 text-violet-500 flex-shrink-0" />}
</button>
))}
</div>
<div className="px-3 py-2 border-t border-slate-100 bg-slate-50/50 flex items-center gap-4 text-[11px] text-slate-400">
<span className="flex items-center gap-1"><kbd className="px-1.5 py-0.5 bg-white rounded border text-slate-500">↑↓</kbd> navigate</span>
<span className="flex items-center gap-1"><kbd className="px-1.5 py-0.5 bg-white rounded border text-slate-500">↵</kbd> select</span>
</div>
</motion.div>
</AnimatePresence>
);
}
7 changes: 5 additions & 2 deletions frontend/src/layouts/UnifiedLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ReactNode } from 'react';
import { Outlet } from 'react-router-dom';
import { Outlet, useLocation } from 'react-router-dom';
import { AppSidebar } from '../components/app-sidebar';
import { UnifiedHeader } from './UnifiedHeader';
import {
Expand All @@ -23,8 +23,11 @@ interface UnifiedLayoutProps {
}

export function UnifiedLayout({ children, hideHeader, headerActions, breadcrumbs }: UnifiedLayoutProps) {
const location = useLocation();
const defaultOpen = location.pathname !== '/';

return (
<SidebarProvider>
<SidebarProvider defaultOpen={defaultOpen}>
<AppSidebar />
<SidebarInset className="h-svh max-h-svh overflow-hidden">
{!hideHeader && (
Expand Down
Loading