From 5ed6b85eeb817ae8d5fc9972a3fe1f317f30a3a6 Mon Sep 17 00:00:00 2001 From: Safwan Erooth Date: Wed, 1 Apr 2026 02:51:39 +0530 Subject: [PATCH 1/2] feat(frontend): add hub simple home command experience --- frontend/src/components/HomeHeaderActions.tsx | 50 +- frontend/src/layouts/UnifiedLayout.tsx | 7 +- frontend/src/pages/HomePage.tsx | 444 +++++++----------- frontend/src/services/commandParser.ts | 113 +++++ 4 files changed, 317 insertions(+), 297 deletions(-) create mode 100644 frontend/src/services/commandParser.ts diff --git a/frontend/src/components/HomeHeaderActions.tsx b/frontend/src/components/HomeHeaderActions.tsx index a49a19a7..e2b4ee99 100644 --- a/frontend/src/components/HomeHeaderActions.tsx +++ b/frontend/src/components/HomeHeaderActions.tsx @@ -3,9 +3,10 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Plus, Bot, Workflow } from 'lucide-react'; +import { ArrowRightLeft, Plus, Bot, Workflow } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; export function HomeHeaderActions() { @@ -20,23 +21,34 @@ export function HomeHeaderActions() { }; return ( - - - - - - - - New Flow - - - - New Agent - - - +
+ + + + + + + + + New Flow + + + + New Agent + + + navigate('/agents')}> + + Open Advanced Hub + + + +
); } diff --git a/frontend/src/layouts/UnifiedLayout.tsx b/frontend/src/layouts/UnifiedLayout.tsx index c516b851..6b7fb4a2 100644 --- a/frontend/src/layouts/UnifiedLayout.tsx +++ b/frontend/src/layouts/UnifiedLayout.tsx @@ -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 { @@ -23,8 +23,11 @@ interface UnifiedLayoutProps { } export function UnifiedLayout({ children, hideHeader, headerActions, breadcrumbs }: UnifiedLayoutProps) { + const location = useLocation(); + const defaultOpen = location.pathname !== '/'; + return ( - + {!hideHeader && ( diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index d3ddc173..9375cb97 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,296 +1,188 @@ -import { useState, useEffect } from 'react'; -import { Info, Loader2 } from 'lucide-react'; -import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/tabs'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../components/ui/tooltip'; -import { Button } from '../components/ui/button'; +import { useMemo, useState } from 'react'; +import { ArrowRight, Command, Sparkles } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { ActiveAgentsTab, ActiveFlowsTab, RecentExecutionsTab } from '../components/dashboard'; -import { getAgentRunsCountLast7Days, getAgentRunsForMetrics, getRecentAgentRuns, type AgentRunMetricsDoc } from '../services/dashboardApi'; -import type { AgentRunDoc } from '../services/agentRunApi'; -import { getAgents } from '../services/agentApi'; -import type { AgentDoc } from '../types/agent.types'; - -interface DashboardMetrics { - totalRuns: number; - successRate: number; - avgRuntime: number; - totalCost: number; -} - -/** - * Calculate success rate from agent runs - */ -function calculateSuccessRate(runs: AgentRunMetricsDoc[]): number { - if (runs.length === 0) return 0; - const successCount = runs.filter( - (run) => run.status === 'Success' || run.status === 'success' - ).length; - return (successCount / runs.length) * 100; -} - -/** - * Calculate average runtime from agent runs - */ -function calculateAvgRuntime(runs: AgentRunMetricsDoc[]): number { - const validRuns = runs.filter( - (run) => run.start_time && run.end_time - ); - - if (validRuns.length === 0) return 0; - - const totalMs = validRuns.reduce((sum, run) => { - try { - const start = new Date(run.start_time!); - const end = new Date(run.end_time!); - - if (isNaN(start.getTime()) || isNaN(end.getTime())) { - return sum; - } - - const diff = end.getTime() - start.getTime(); - return diff >= 0 ? sum + diff : sum; - } catch { - return sum; - } - }, 0); - - return totalMs / validRuns.length; -} - -/** - * Calculate total cost from agent runs - */ -function calculateTotalCost(runs: AgentRunMetricsDoc[]): number { - return runs.reduce((sum, run) => { - const cost = run.cost; - return sum + (typeof cost === 'number' && !isNaN(cost) ? cost : 0); - }, 0); -} - -/** - * Format duration in milliseconds to human-readable string - */ -function formatDuration(ms: number): string { - if (ms < 1000) { - return `${Math.round(ms)}ms`; - } - if (ms < 60000) { - return `${(ms / 1000).toFixed(1)}s`; - } - const minutes = Math.floor(ms / 60000); - const seconds = Math.floor((ms % 60000) / 1000); - return `${minutes}m ${seconds}s`; -} - -/** - * Format number with commas - */ -function formatNumber(num: number): string { - return num.toLocaleString('en-US'); -} - -/** - * Format currency with up to 4 decimal places - * Shows more decimal places for very small values - */ -function formatCurrency(amount: number): string { - // Format with up to 4 decimal places, removing trailing zeros - const formatted = amount.toFixed(4); - // Remove trailing zeros but keep at least 2 decimal places - const trimmed = formatted.replace(/\.?0+$/, ''); - - // If no decimal point, add .00 - if (!trimmed.includes('.')) { - return `$${trimmed}.00`; - } - - // Ensure at least 2 decimal places for consistency - const parts = trimmed.split('.'); - const decimals = parts[1]; - if (decimals.length < 2) { - return `$${parts[0]}.${decimals.padEnd(2, '0')}`; - } - - return `$${trimmed}`; -} +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { parseSlashCommand, type ParsedCommand } from '@/services/commandParser'; + +const starterPrompts = [ + '/flow create approval for todo', + '/agent create support assistant for billing', + '/users add teammate as builder in realm operations', + '/runs retry failed since yesterday', + '/cost show last 7 days by model', +]; + +const commandCatalog = [ + { domain: 'flow', hint: 'Create and manage automations', route: '/flows' }, + { domain: 'agent', hint: 'Build and manage agents', route: '/agents' }, + { domain: 'users', hint: 'Invite/remove users', route: '/users' }, + { domain: 'knowledge', hint: 'Index and query sources', route: '/knowledge' }, + { domain: 'runs', hint: 'Inspect and retry executions', route: '/executions' }, + { domain: 'cost', hint: 'Analyze usage and cost', route: '/executions' }, + { domain: 'realm', hint: 'Realm-level operations', route: '/users' }, +] as const; + +const domainRouteMap = Object.fromEntries(commandCatalog.map((item) => [item.domain, item.route])); export { HomePage }; export default HomePage; function HomePage() { const navigate = useNavigate(); - const [activeTab, setActiveTab] = useState('agents'); - const [metrics, setMetrics] = useState({ - totalRuns: 0, - successRate: 0, - avgRuntime: 0, - totalCost: 0, - }); - const [metricsLoading, setMetricsLoading] = useState(true); - - // Data for tabs - loaded once on mount - const [agents, setAgents] = useState([]); - const [agentsLoading, setAgentsLoading] = useState(true); - const [agentRuns, setAgentRuns] = useState([]); - const [agentRunsLoading, setAgentRunsLoading] = useState(true); - - useEffect(() => { - async function fetchAllData() { - try { - // Fetch all data in parallel - const [totalRuns, runsData, agentsData, recentRuns] = await Promise.all([ - getAgentRunsCountLast7Days(), - getAgentRunsForMetrics(), - getAgents({ - status: 'active', - limit: 10, - page: 1, - }), - getRecentAgentRuns(), - ]); - - // Process metrics - const successRate = calculateSuccessRate(runsData); - const avgRuntime = calculateAvgRuntime(runsData); - const totalCost = calculateTotalCost(runsData); - - setMetrics({ - totalRuns, - successRate, - avgRuntime, - totalCost, - }); - - // Process agents - const agentList = Array.isArray(agentsData) ? agentsData : agentsData.items; - const activeAgents = agentList.filter((agent) => agent.disabled === 0); - setAgents(activeAgents.slice(0, 10)); - - // Set agent runs - setAgentRuns(recentRuns); - } catch (error) { - console.error('Error fetching dashboard data:', error); - } finally { - setMetricsLoading(false); - setAgentsLoading(false); - setAgentRunsLoading(false); - } + const [input, setInput] = useState(''); + const [error, setError] = useState(null); + const [lastParsed, setLastParsed] = useState(null); + + const showCommandPicker = input.trim().startsWith('/'); + + const filteredCatalog = useMemo(() => { + if (!showCommandPicker) return commandCatalog; + const needle = input.replace('/', '').trim().toLowerCase(); + if (!needle) return commandCatalog; + return commandCatalog.filter((item) => item.domain.includes(needle) || item.hint.toLowerCase().includes(needle)); + }, [input, showCommandPicker]); + + const handleSubmit = () => { + const trimmed = input.trim(); + if (!trimmed) return; + + if (!trimmed.startsWith('/')) { + navigate('/chat'); + return; } - fetchAllData(); - }, []); - - const metricsData = [ - { - id: 'total-runs', - title: 'Total Agent Runs', - subtitle: 'Last 7 days', - value: metricsLoading ? '...' : formatNumber(metrics.totalRuns), - tooltip: 'Total number of agent executions in the last 7 days', - }, - { - id: 'success-rate', - title: 'Success Rate', - subtitle: 'Last 7 days', - value: metricsLoading ? '...' : `${metrics.successRate.toFixed(1)}%`, - tooltip: 'Percentage of successful agent runs without errors', - }, - { - id: 'avg-runtime', - title: 'Avg Runtime', - subtitle: 'Last 7 days', - value: metricsLoading ? '...' : formatDuration(metrics.avgRuntime), - tooltip: 'Average execution time across all agent runs', - }, - { - id: 'cost', - title: 'Total Cost', - subtitle: 'Last 7 days', - value: metricsLoading ? '...' : formatCurrency(metrics.totalCost), - tooltip: 'Total API costs for LLM usage across all agents', - }, - ]; + try { + const parsed = parseSlashCommand(trimmed); + setLastParsed(parsed); + setError(null); + + const route = domainRouteMap[parsed.domain] ?? '/'; + navigate(route); + } catch (parseError) { + const message = parseError instanceof Error ? parseError.message : 'Unable to parse command.'; + setError(message); + setLastParsed(null); + } + }; return (
-
-
-

Dashboard

-

- Monitor your agents, flows, and system performance +

+
+
+ + Hub Simple powered by Huf orchestrator +
+

What are you building today?

+

+ Use natural language or type / for a command. Commands route to focused sub-agents such as + flow, users, and cost operations.

-
- {/* Metrics Cards */} -
- - {metricsData.map((metric) => ( - - - - {metric.title} - - - - - - -

{metric.tooltip}

-
-
-
- -
- {metric.subtitle} -
-
- {metricsLoading && metric.value === '...' ? ( - - ) : ( - metric.value - )} -
-
-
- ))} -
-
+
+ setInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleSubmit(); + } + }} + placeholder="Try: /flow create approval for todo" + className="h-11" + /> + + +
- {/* Tabbed Interface */} - -
- - Agents - Flows - Executions - - {activeTab === 'agents' && ( - + ))} +
+
+ )} + + {error &&

{error}

} + + {lastParsed && ( +
+
Parsed command
+
+ {lastParsed.routingMode} → {lastParsed.domain} / {lastParsed.verb} +
+
+ )} + + +
+

Starter actions

+
+ {starterPrompts.map((prompt) => ( + - )} + {prompt} + + ))}
- - {/* Agents Tab */} - - - - - {/* Flows Tab */} - - - - - {/* Executions Tab */} - - - - +
+ +
+ + + Permissions + Huf roles and agent-level controls are applied automatically. + + + No new privilege model + + + + + Orchestration + Requests are delegated to domain agents for clearer scope. + + + Orchestrator + Sub-agents + + + + + Need full control? + Switch to the existing Hub interface at any time. + + + + + +
); diff --git a/frontend/src/services/commandParser.ts b/frontend/src/services/commandParser.ts new file mode 100644 index 00000000..9340eef5 --- /dev/null +++ b/frontend/src/services/commandParser.ts @@ -0,0 +1,113 @@ +export type CommandDomain = 'flow' | 'agent' | 'users' | 'knowledge' | 'runs' | 'cost' | 'realm'; + +export interface CommandQualifier { + key: string; + value: string; +} + +export interface ParsedCommand { + raw: string; + routingMode: 'domain' | 'explicit-agent'; + domain: CommandDomain; + verb: string; + objectPhrase: string; + qualifiers: CommandQualifier[]; +} + +const SUPPORTED_DOMAINS: CommandDomain[] = ['flow', 'agent', 'users', 'knowledge', 'runs', 'cost', 'realm']; +const QUALIFIER_KEYS = new Set(['for', 'in', 'as', 'since', 'by', 'with', 'from', 'to']); +const VERB_ALIASES: Record = { + make: 'create', + new: 'create', + del: 'delete', + rm: 'remove', + ls: 'list', +}; + +function normalizeVerb(verb: string): string { + const lowerVerb = verb.toLowerCase(); + return VERB_ALIASES[lowerVerb] ?? lowerVerb; +} + +function parseParts(tokens: string[]): { objectPhrase: string; qualifiers: CommandQualifier[] } { + const qualifiers: CommandQualifier[] = []; + const objectTokens: string[] = []; + + let i = 0; + while (i < tokens.length) { + const token = tokens[i].toLowerCase(); + + if (QUALIFIER_KEYS.has(token)) { + const key = token; + const valueTokens: string[] = []; + i += 1; + + while (i < tokens.length && !QUALIFIER_KEYS.has(tokens[i].toLowerCase())) { + valueTokens.push(tokens[i]); + i += 1; + } + + qualifiers.push({ + key, + value: valueTokens.join(' ').trim(), + }); + continue; + } + + objectTokens.push(tokens[i]); + i += 1; + } + + return { + objectPhrase: objectTokens.join(' ').trim(), + qualifiers, + }; +} + +/** + * Parse slash commands in Hub Simple home. + * Supports: + * - /flow create approval for todo + * - /agent:users add Sarah as builder in realm ops + */ +export function parseSlashCommand(rawInput: string): ParsedCommand { + const raw = rawInput.trim(); + + if (!raw.startsWith('/')) { + throw new Error('Command must start with /.'); + } + + const withoutSlash = raw.slice(1).trim(); + const tokens = withoutSlash.split(/\s+/).filter(Boolean); + + if (tokens.length < 2) { + throw new Error('Command must include a domain and verb. Example: /flow create approval for todo'); + } + + const commandHead = tokens[0].toLowerCase(); + const remainder = tokens.slice(1); + + let routingMode: ParsedCommand['routingMode'] = 'domain'; + let domainToken = commandHead; + + if (commandHead.startsWith('agent:')) { + routingMode = 'explicit-agent'; + domainToken = commandHead.replace('agent:', ''); + } + + if (!SUPPORTED_DOMAINS.includes(domainToken as CommandDomain)) { + throw new Error(`Unknown domain "${domainToken}". Try one of: ${SUPPORTED_DOMAINS.join(', ')}`); + } + + const verb = normalizeVerb(remainder[0]); + const { objectPhrase, qualifiers } = parseParts(remainder.slice(1)); + + return { + raw, + routingMode, + domain: domainToken as CommandDomain, + verb, + objectPhrase, + qualifiers, + }; +} From 822fccd167fc88abb4f5c66bc5ebda292593f1c3 Mon Sep 17 00:00:00 2001 From: esafwan Date: Tue, 2 Jun 2026 07:32:56 +0400 Subject: [PATCH 2/2] feat(frontend): implement Hub Simple home with real agent chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add HubSimplePage as default '/' route (chat-first, no UnifiedLayout) - Add '/dashboard' route for existing metrics dashboard (agents/flows/executions tabs) - Collapsed 60px icon sidebar with nav to all major sections - Role-aware greetings and starter prompts (admin/builder/operator/viewer) - Starter prompts navigate to real pages; free-text triggers real agent chat - Slash command palette (/) with keyboard navigation → navigates to real routes - Real agent chat via 'Hub Orchestrator' with SSE streaming + delta updates - Provider check on mount: no provider → amber onboarding card with link to /models - Streaming delta updates using same pattern as ChatInput (streamingAvailable flag) - HomeHeaderActions: replace switch button with '← Hub' ghost button back to / - SlashCommandMenu and HubConversationView extracted to frontend/src/components/hub/ --- frontend/src/App.tsx | 11 + frontend/src/components/HomeHeaderActions.tsx | 14 +- .../components/hub/HubConversationView.tsx | 138 +++++++ .../src/components/hub/SlashCommandMenu.tsx | 96 +++++ frontend/src/pages/HubSimplePage.tsx | 369 ++++++++++++++++++ 5 files changed, 618 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/hub/HubConversationView.tsx create mode 100644 frontend/src/components/hub/SlashCommandMenu.tsx create mode 100644 frontend/src/pages/HubSimplePage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b836b5c0..0b1207cf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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'; @@ -117,6 +118,16 @@ function App() { + }> + + + + } + /> + }> diff --git a/frontend/src/components/HomeHeaderActions.tsx b/frontend/src/components/HomeHeaderActions.tsx index e2b4ee99..7ac8eee2 100644 --- a/frontend/src/components/HomeHeaderActions.tsx +++ b/frontend/src/components/HomeHeaderActions.tsx @@ -3,10 +3,9 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { ArrowRightLeft, Plus, Bot, Workflow } from 'lucide-react'; +import { ChevronLeft, Plus, Bot, Workflow } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; export function HomeHeaderActions() { @@ -22,9 +21,9 @@ export function HomeHeaderActions() { return (
- @@ -42,11 +41,6 @@ export function HomeHeaderActions() { New Agent - - navigate('/agents')}> - - Open Advanced Hub -
diff --git a/frontend/src/components/hub/HubConversationView.tsx b/frontend/src/components/hub/HubConversationView.tsx new file mode 100644 index 00000000..91dba027 --- /dev/null +++ b/frontend/src/components/hub/HubConversationView.tsx @@ -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(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 ( +
+ {/* Messages */} +
+ {messages.map((msg, i) => ( + +
+ {msg.role === 'user' ? ( +
+ {initials} +
+ ) : ( +
+ +
+ )} +
+
+ {msg.role === 'assistant' && ( +
+ Hub Orchestrator + System +
+ )} + {msg.content === '__NO_PROVIDER__' ? ( +
+

No AI Provider configured

+

Add a provider and model to start using Hub Orchestrator.

+ + Add Provider → + +
+ ) : ( +
+ {msg.content} +
+ )} +
+
+ ))} + + {/* Typing indicator */} + {(messages.length > 0 && messages[messages.length - 1].role === 'user') || isStreaming ? ( + +
+ +
+
+ {[0, 0.15, 0.3].map((delay, i) => ( + + ))} +
+
+ ) : null} +
+ + {/* Input */} +
+
+ +
+