From 27472826e6f8eb03ddda1e563f2f0e602d0b9cbe Mon Sep 17 00:00:00 2001 From: Krishna Mohan Date: Wed, 4 Feb 2026 18:44:35 +0530 Subject: [PATCH 1/2] fix: fixed the linting issue Signed-off-by: Krishna Mohan --- frontend/src/App.tsx | 4 +- .../components/chat/ChatCommandPalette.tsx | 425 +++++++++++++++++ frontend/src/components/chat/ChatSidebar.tsx | 293 ++++++++++++ .../src/components/chat/SuggestedPrompts.tsx | 152 ++++++ .../components/chat/WorkflowPreviewPanel.tsx | 218 +++++++++ frontend/src/components/chat/index.ts | 4 + frontend/src/components/layout/AppLayout.tsx | 222 +++++++-- frontend/src/index.css | 216 ++++++++- frontend/src/pages/ChatInterface.tsx | 447 ++++++++++++++++++ frontend/src/store/chatStore.ts | 324 +++++++++++++ 10 files changed, 2235 insertions(+), 70 deletions(-) create mode 100644 frontend/src/components/chat/ChatCommandPalette.tsx create mode 100644 frontend/src/components/chat/ChatSidebar.tsx create mode 100644 frontend/src/components/chat/SuggestedPrompts.tsx create mode 100644 frontend/src/components/chat/WorkflowPreviewPanel.tsx create mode 100644 frontend/src/components/chat/index.ts create mode 100644 frontend/src/pages/ChatInterface.tsx create mode 100644 frontend/src/store/chatStore.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 26139b94..fea75a05 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { ChatInterface } from '@/pages/ChatInterface'; import { WorkflowList } from '@/pages/WorkflowList'; import { WorkflowBuilder } from '@/features/workflow-builder/WorkflowBuilder'; import { SecretsManager } from '@/pages/SecretsManager'; @@ -49,7 +50,8 @@ function App() { - } /> + } /> + } /> ; + category: 'chat' | 'workflow' | 'navigation' | 'actions'; + action: () => void; + keywords?: string[]; +} + +interface ChatCommandPaletteProps { + onSelectPrompt?: (prompt: string) => void; +} + +export function ChatCommandPalette({ onSelectPrompt }: ChatCommandPaletteProps) { + const navigate = useNavigate(); + const [query, setQuery] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + const { + isCommandPaletteOpen, + setCommandPaletteOpen, + createSession, + clearSession, + currentSessionId, + } = useChatStore(); + + // Define all commands + const commands: CommandItem[] = useMemo(() => { + const baseCommands: CommandItem[] = [ + // Chat commands + { + id: 'new-chat', + label: 'New Chat', + description: 'Start a new conversation', + icon: Plus, + category: 'chat', + action: () => { + createSession(); + setCommandPaletteOpen(false); + }, + keywords: ['create', 'start', 'conversation'], + }, + { + id: 'clear-chat', + label: 'Clear Chat', + description: 'Clear current conversation', + icon: Trash2, + category: 'chat', + action: () => { + if (currentSessionId) { + clearSession(currentSessionId); + } + setCommandPaletteOpen(false); + }, + keywords: ['delete', 'remove', 'reset'], + }, + + // Workflow commands + { + id: 'create-workflow', + label: 'Create Workflow', + description: 'Build a new automation workflow', + icon: Workflow, + category: 'workflow', + action: () => { + navigate('/workflows/new'); + setCommandPaletteOpen(false); + }, + keywords: ['automation', 'build', 'new'], + }, + { + id: 'run-workflow', + label: 'Run Workflow', + description: 'Execute an existing workflow', + icon: Play, + category: 'workflow', + action: () => { + navigate('/workflows'); + setCommandPaletteOpen(false); + }, + keywords: ['execute', 'start', 'trigger'], + }, + { + id: 'view-history', + label: 'Run History', + description: 'View recent workflow executions', + icon: History, + category: 'workflow', + action: () => { + navigate('/runs'); + setCommandPaletteOpen(false); + }, + keywords: ['logs', 'executions', 'past'], + }, + + // Navigation commands + { + id: 'go-workflows', + label: 'Go to Workflows', + description: 'View all workflows', + icon: Workflow, + category: 'navigation', + action: () => { + navigate('/workflows'); + setCommandPaletteOpen(false); + }, + keywords: ['navigate', 'open'], + }, + { + id: 'go-components', + label: 'Go to Components', + description: 'Browse available components', + icon: Code, + category: 'navigation', + action: () => { + navigate('/components'); + setCommandPaletteOpen(false); + }, + keywords: ['navigate', 'open', 'nodes'], + }, + { + id: 'go-settings', + label: 'Settings', + description: 'Open application settings', + icon: Settings, + category: 'navigation', + action: () => { + navigate('/settings'); + setCommandPaletteOpen(false); + }, + keywords: ['preferences', 'config', 'configure'], + }, + + // Action commands (prompts) + { + id: 'prompt-analyze', + label: 'Analyze Code', + description: 'Get code analysis and suggestions', + icon: Code, + category: 'actions', + action: () => { + onSelectPrompt?.('Analyze this code and suggest improvements:'); + setCommandPaletteOpen(false); + }, + keywords: ['review', 'code', 'suggestions'], + }, + { + id: 'prompt-debug', + label: 'Debug Issue', + description: 'Get help debugging a problem', + icon: Bug, + category: 'actions', + action: () => { + onSelectPrompt?.('Help me debug this issue:'); + setCommandPaletteOpen(false); + }, + keywords: ['fix', 'error', 'problem'], + }, + { + id: 'prompt-explain', + label: 'Explain Concept', + description: 'Learn about a concept or feature', + icon: BookOpen, + category: 'actions', + action: () => { + onSelectPrompt?.('Explain to me how'); + setCommandPaletteOpen(false); + }, + keywords: ['learn', 'understand', 'teach'], + }, + { + id: 'help', + label: 'Help & Documentation', + description: 'Get help and view docs', + icon: HelpCircle, + category: 'navigation', + action: () => { + navigate('/docs'); + setCommandPaletteOpen(false); + }, + keywords: ['docs', 'documentation', 'support'], + }, + ]; + + return baseCommands; + }, [ + navigate, + createSession, + clearSession, + currentSessionId, + setCommandPaletteOpen, + onSelectPrompt, + ]); + + // Filter commands based on query + const filteredCommands = useMemo(() => { + if (!query.trim()) return commands; + + const lowerQuery = query.toLowerCase(); + return commands.filter( + (cmd) => + cmd.label.toLowerCase().includes(lowerQuery) || + cmd.description?.toLowerCase().includes(lowerQuery) || + cmd.keywords?.some((k) => k.toLowerCase().includes(lowerQuery)), + ); + }, [commands, query]); + + // Group filtered commands by category + const groupedCommands = useMemo(() => { + const groups: Record = {}; + filteredCommands.forEach((cmd) => { + if (!groups[cmd.category]) groups[cmd.category] = []; + groups[cmd.category].push(cmd); + }); + return groups; + }, [filteredCommands]); + + const categoryLabels: Record = { + chat: 'Chat', + workflow: 'Workflows', + navigation: 'Navigation', + actions: 'Quick Actions', + }; + + const categoryOrder = ['chat', 'workflow', 'actions', 'navigation']; + + // Flatten for keyboard navigation + const flatCommands = useMemo(() => { + return categoryOrder + .filter((cat) => groupedCommands[cat]) + .flatMap((cat) => groupedCommands[cat]); + }, [groupedCommands]); + + // Keyboard navigation + useEffect(() => { + if (selectedIndex >= flatCommands.length) { + setSelectedIndex(Math.max(0, flatCommands.length - 1)); + } + }, [flatCommands.length, selectedIndex]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, flatCommands.length - 1)); + break; + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + break; + case 'Enter': + e.preventDefault(); + if (flatCommands[selectedIndex]) { + flatCommands[selectedIndex].action(); + } + break; + case 'Escape': + e.preventDefault(); + setCommandPaletteOpen(false); + break; + } + }, + [flatCommands, selectedIndex, setCommandPaletteOpen], + ); + + // Reset state when opened + useEffect(() => { + if (isCommandPaletteOpen) { + setQuery(''); + setSelectedIndex(0); + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [isCommandPaletteOpen]); + + // Global keyboard shortcut + useEffect(() => { + const handleGlobalKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setCommandPaletteOpen(!isCommandPaletteOpen); + } + if ( + e.key === '/' && + !isCommandPaletteOpen && + !(e.target instanceof HTMLInputElement) && + !(e.target instanceof HTMLTextAreaElement) + ) { + e.preventDefault(); + setCommandPaletteOpen(true); + } + }; + + window.addEventListener('keydown', handleGlobalKeyDown); + return () => window.removeEventListener('keydown', handleGlobalKeyDown); + }, [isCommandPaletteOpen, setCommandPaletteOpen]); + + return ( + + + {/* Search Input */} +
+ + { + setQuery(e.target.value); + setSelectedIndex(0); + }} + placeholder="Type a command or search..." + className="flex-1 bg-transparent text-foreground text-base placeholder:text-muted-foreground/60 outline-none" + /> + + ESC + +
+ + {/* Commands List */} +
+ {flatCommands.length === 0 ? ( +
+ +

No commands found

+

Try a different search term

+
+ ) : ( + categoryOrder + .filter((cat) => groupedCommands[cat]) + .map((category) => ( +
+
+ {categoryLabels[category]} +
+ {groupedCommands[category].map((cmd) => { + const Icon = cmd.icon; + const isSelected = flatCommands[selectedIndex]?.id === cmd.id; + + return ( + + ); + })} +
+ )) + )} +
+ + {/* Footer */} +
+
+ + + + Navigate + + + + Select + +
+ + + K + Toggle + +
+
+
+ ); +} diff --git a/frontend/src/components/chat/ChatSidebar.tsx b/frontend/src/components/chat/ChatSidebar.tsx new file mode 100644 index 00000000..2a77a9c0 --- /dev/null +++ b/frontend/src/components/chat/ChatSidebar.tsx @@ -0,0 +1,293 @@ +import { useState } from 'react'; +import { Link, useNavigate, useLocation } from 'react-router-dom'; +import { + Plus, + MessageSquare, + Folder, + Box, + Code, + Workflow, + Search, + Trash2, + ChevronDown, + ChevronRight, + Settings, + HelpCircle, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useChatStore, type ChatSession } from '@/store/chatStore'; +import { UserButton } from '@/components/auth/UserButton'; + +interface NavItemProps { + icon: React.ComponentType<{ className?: string }>; + label: string; + to: string; + active?: boolean; + badge?: string | number; +} + +const NavItem = ({ icon: Icon, label, to, active, badge }: NavItemProps) => ( + +
+ + {label} +
+ {badge && ( + + {badge} + + )} + +); + +interface ChatHistoryItemProps { + session: ChatSession; + isActive: boolean; + onSelect: () => void; + onDelete: () => void; + onRename: (title: string) => void; +} + +const ChatHistoryItem = ({ + session, + isActive, + onSelect, + onDelete, + onRename, +}: ChatHistoryItemProps) => { + const [isEditing, setIsEditing] = useState(false); + const [editTitle, setEditTitle] = useState(session.title); + + const handleRename = () => { + if (editTitle.trim() && editTitle.trim() !== session.title) { + onRename(editTitle.trim()); + } + setIsEditing(false); + }; + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + onDelete(); + }; + + const handleDoubleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setEditTitle(session.title); + setIsEditing(true); + }; + + return ( +
+ + + {isEditing ? ( + setEditTitle(e.target.value)} + onBlur={handleRename} + onKeyDown={(e) => { + if (e.key === 'Enter') handleRename(); + if (e.key === 'Escape') { + setEditTitle(session.title); + setIsEditing(false); + } + }} + className="flex-1 bg-white/10 px-2 py-0.5 rounded text-sm text-white outline-none border border-white/20 min-w-0" + autoFocus + onClick={(e) => e.stopPropagation()} + /> + ) : ( + <> + + {session.title} + + {/* Delete button - inline in the row, appears on hover */} + + + )} +
+ ); +}; + +interface ChatSidebarProps { + className?: string; +} + +export function ChatSidebar({ className }: ChatSidebarProps) { + const navigate = useNavigate(); + const location = useLocation(); + const [searchQuery, setSearchQuery] = useState(''); + const [isHistoryExpanded, setIsHistoryExpanded] = useState(true); + + const { + sessions, + currentSessionId, + createSession, + deleteSession, + setCurrentSession, + updateSessionTitle, + } = useChatStore(); + + const filteredSessions = sessions.filter( + (session) => + session.title.toLowerCase().includes(searchQuery.toLowerCase()) || + session.messages.some((msg) => msg.content.toLowerCase().includes(searchQuery.toLowerCase())), + ); + + // Group sessions by date + const groupedSessions = filteredSessions.reduce( + (groups, session) => { + const date = new Date(session.updatedAt); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + let groupKey: string; + if (days === 0) groupKey = 'Today'; + else if (days === 1) groupKey = 'Yesterday'; + else if (days < 7) groupKey = 'Previous 7 Days'; + else if (days < 30) groupKey = 'Previous 30 Days'; + else groupKey = 'Older'; + + if (!groups[groupKey]) groups[groupKey] = []; + groups[groupKey].push(session); + return groups; + }, + {} as Record, + ); + + const handleNewChat = () => { + createSession(); + navigate('/'); + }; + + return ( +
+ {/* Header with New Chat */} +
+ + + {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search chats..." + className="w-full pl-9 pr-3 py-2 text-sm bg-white/5 border border-white/5 rounded-lg text-white placeholder:text-[#666] focus:outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/20 transition-all" + /> +
+
+ + {/* Navigation */} +
+ + + + + +
+ + {/* Chat History */} +
+ + + {isHistoryExpanded && ( +
+ {Object.entries(groupedSessions).length === 0 ? ( +
+ +

No chats yet

+

Start a new conversation

+
+ ) : ( + Object.entries(groupedSessions).map(([group, groupSessions]) => ( +
+
+ {group} +
+
+ {groupSessions.map((session) => ( + setCurrentSession(session.id)} + onDelete={() => deleteSession(session.id)} + onRename={(title) => updateSessionTitle(session.id, title)} + /> + ))} +
+
+ )) + )} +
+ )} +
+ + {/* Bottom Actions */} +
+ + + +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/chat/SuggestedPrompts.tsx b/frontend/src/components/chat/SuggestedPrompts.tsx new file mode 100644 index 00000000..74306b6a --- /dev/null +++ b/frontend/src/components/chat/SuggestedPrompts.tsx @@ -0,0 +1,152 @@ +import { Zap, GraduationCap, Code, Coffee, Sparkles, Workflow, Bug, FileText } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { type SuggestedPrompt } from '@/store/chatStore'; + +const iconMap: Record> = { + Zap, + GraduationCap, + Code, + Coffee, + Sparkles, + Workflow, + Bug, + FileText, +}; + +// Extended prompts with more specific suggestions +export const extendedPrompts: SuggestedPrompt[] = [ + { + id: 'create-workflow', + icon: 'Workflow', + label: 'Create workflow', + prompt: 'Help me create a new workflow that', + category: 'workflow', + }, + { + id: 'write-code', + icon: 'Zap', + label: 'Write code', + prompt: 'Write code that', + category: 'code', + }, + { + id: 'learn', + icon: 'GraduationCap', + label: 'Learn', + prompt: 'Teach me about', + category: 'learn', + }, + { + id: 'analyze', + icon: 'Code', + label: 'Analyze', + prompt: 'Analyze this and provide insights:', + category: 'code', + }, + { + id: 'life-stuff', + icon: 'Coffee', + label: 'Life stuff', + prompt: 'Help me with', + category: 'general', + }, + { + id: 'surprise-me', + icon: 'Sparkles', + label: 'Surprise me', + prompt: 'Show me something interesting I can build with ShipSec workflows', + category: 'general', + }, +]; + +interface SuggestedPromptsProps { + onSelectPrompt: (prompt: string) => void; + className?: string; + variant?: 'pills' | 'cards'; +} + +export function SuggestedPrompts({ + onSelectPrompt, + className, + variant = 'pills', +}: SuggestedPromptsProps) { + if (variant === 'cards') { + return ( +
+ {extendedPrompts.slice(0, 6).map((prompt) => { + const Icon = iconMap[prompt.icon] || Sparkles; + return ( + + ); + })} +
+ ); + } + + return ( +
+ {extendedPrompts.map((prompt) => { + const Icon = iconMap[prompt.icon] || Sparkles; + return ( + + ); + })} +
+ ); +} + +interface QuickActionsBarProps { + onAction: (action: string) => void; + className?: string; +} + +export function QuickActionsBar({ onAction, className }: QuickActionsBarProps) { + const actions = [ + { id: 'new-workflow', icon: Workflow, label: 'New Workflow', command: 'Create a new workflow' }, + { id: 'debug', icon: Bug, label: 'Debug', command: 'Help me debug:' }, + { + id: 'docs', + icon: FileText, + label: 'Documentation', + command: 'Show me the documentation for', + }, + ]; + + return ( +
+ {actions.map((action) => { + const Icon = action.icon; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/chat/WorkflowPreviewPanel.tsx b/frontend/src/components/chat/WorkflowPreviewPanel.tsx new file mode 100644 index 00000000..77edb29f --- /dev/null +++ b/frontend/src/components/chat/WorkflowPreviewPanel.tsx @@ -0,0 +1,218 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + X, + Play, + Edit, + ExternalLink, + Clock, + AlertCircle, + Workflow, + Loader2, + Calendar, + FileCode, +} from 'lucide-react'; +import { useChatStore } from '@/store/chatStore'; +import { api } from '@/services/api'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; + +interface WorkflowDetails { + id: string; + name: string; + description?: string; + status?: string; + lastRun?: Date; + createdAt?: Date; + updatedAt?: Date; + nodeCount?: number; + version?: number; +} + +export function WorkflowPreviewPanel() { + const navigate = useNavigate(); + const [workflow, setWorkflow] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isRunning, setIsRunning] = useState(false); + + const { isWorkflowPreviewOpen, selectedWorkflowId, setWorkflowPreviewOpen, setSelectedWorkflow } = + useChatStore(); + + useEffect(() => { + if (selectedWorkflowId && isWorkflowPreviewOpen) { + loadWorkflow(selectedWorkflowId); + } + }, [selectedWorkflowId, isWorkflowPreviewOpen]); + + const loadWorkflow = async (id: string) => { + setIsLoading(true); + try { + const data = await api.workflows.get(id); + setWorkflow({ + id: data.id, + name: data.name, + description: data.description ?? undefined, + createdAt: data.createdAt ? new Date(data.createdAt) : undefined, + updatedAt: data.updatedAt ? new Date(data.updatedAt) : undefined, + version: data.currentVersion ?? undefined, + }); + } catch (error) { + console.error('Failed to load workflow', error); + } finally { + setIsLoading(false); + } + }; + + const handleRunWorkflow = async () => { + if (!workflow) return; + setIsRunning(true); + try { + await api.workflows.run(workflow.id); + // Could add a toast notification here + } catch (error) { + console.error('Failed to run workflow', error); + } finally { + setIsRunning(false); + } + }; + + const handleClose = () => { + setWorkflowPreviewOpen(false); + setSelectedWorkflow(null); + }; + + const handleOpenWorkflow = () => { + if (workflow) { + navigate(`/workflows/${workflow.id}`); + handleClose(); + } + }; + + if (!isWorkflowPreviewOpen || !selectedWorkflowId) return null; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Workflow Preview

+

Quick actions & details

+
+
+ +
+ + {/* Content */} +
+ {isLoading ? ( +
+ +

Loading workflow...

+
+ ) : workflow ? ( + <> + {/* Workflow Name */} +
+

{workflow.name}

+ {workflow.description && ( +

{workflow.description}

+ )} +
+ + {/* Quick Actions */} +
+ + +
+ + {/* Workflow Info */} +
+

Details

+ +
+ {workflow.version && ( +
+ + Version + + v{workflow.version} + +
+ )} + + {workflow.createdAt && ( +
+ + Created + + {workflow.createdAt.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + +
+ )} + + {workflow.updatedAt && ( +
+ + Updated + + {workflow.updatedAt.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + +
+ )} +
+
+ + {/* Open Full View */} + + + ) : ( +
+ +

Workflow not found

+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/chat/index.ts b/frontend/src/components/chat/index.ts new file mode 100644 index 00000000..8730e3c6 --- /dev/null +++ b/frontend/src/components/chat/index.ts @@ -0,0 +1,4 @@ +export { ChatSidebar } from './ChatSidebar'; +export { ChatCommandPalette } from './ChatCommandPalette'; +export { WorkflowPreviewPanel } from './WorkflowPreviewPanel'; +export { SuggestedPrompts, QuickActionsBar, extendedPrompts } from './SuggestedPrompts'; diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index bd0bf44f..04d4d79d 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -23,6 +23,10 @@ import { Command, Zap, Webhook, + MessageSquare, + ChevronDown, + ChevronRight, + Trash2, } from 'lucide-react'; import React, { useState, useEffect, useCallback } from 'react'; import { useAuthStore } from '@/store/authStore'; @@ -34,6 +38,7 @@ import { useThemeStore } from '@/store/themeStore'; import { cn } from '@/lib/utils'; import { setMobilePlacementSidebarClose } from '@/components/layout/sidebar-state'; import { useCommandPaletteStore } from '@/store/commandPaletteStore'; +import { useChatStore } from '@/store/chatStore'; interface AppLayoutProps { children: React.ReactNode; @@ -61,11 +66,14 @@ function useIsMobile(breakpoint = 768) { export function AppLayout({ children }: AppLayoutProps) { const isMobile = useIsMobile(); + const location = useLocation(); + const navigate = useNavigate(); + // Check if we are on the Chat Interface (root path) + const isChatInterface = location.pathname === '/'; + const [sidebarOpen, setSidebarOpen] = useState(!isMobile); const [, setIsHovered] = useState(false); const [wasExplicitlyOpened, setWasExplicitlyOpened] = useState(!isMobile); - const location = useLocation(); - const navigate = useNavigate(); const roles = useAuthStore((state) => state.roles); const canManageWorkflows = hasAdminRole(roles); const { isAuthenticated } = useAuth(); @@ -74,6 +82,11 @@ export function AppLayout({ children }: AppLayoutProps) { const { theme, startTransition } = useThemeStore(); const openCommandPalette = useCommandPaletteStore((state) => state.open); + // Chat store for history in sidebar + const { sessions, currentSessionId, setCurrentSession, createSession, deleteSession } = + useChatStore(); + const [isChatHistoryExpanded, setIsChatHistoryExpanded] = useState(true); + // Get git SHA for version display (monorepo - same for frontend and backend) const gitSha = env.VITE_GIT_SHA; // If it's a tag (starts with v), show full tag. Otherwise show first 7 chars of SHA @@ -92,11 +105,18 @@ export function AppLayout({ children }: AppLayoutProps) { setWasExplicitlyOpened(false); } else { const isWorkflowRoute = - (location.pathname.startsWith('/workflows') || + (location.pathname.startsWith('/workflows/') || location.pathname.startsWith('/webhooks/')) && location.pathname !== '/'; - setSidebarOpen(!isWorkflowRoute); - setWasExplicitlyOpened(!isWorkflowRoute); + // For chat interface, maintain sidebar state based on user preference + if (location.pathname === '/') { + // Keep sidebar open by default on chat interface + setSidebarOpen(true); + setWasExplicitlyOpened(true); + } else { + setSidebarOpen(!isWorkflowRoute); + setWasExplicitlyOpened(!isWorkflowRoute); + } } }, [location.pathname, isMobile]); @@ -256,8 +276,13 @@ export function AppLayout({ children }: AppLayoutProps) { const navigationItems = [ { - name: 'Workflow Builder', + name: 'AI Chat', href: '/', + icon: MessageSquare, + }, + { + name: 'Workflow Builder', + href: '/workflows', icon: Workflow, }, { @@ -303,7 +328,10 @@ export function AppLayout({ children }: AppLayoutProps) { const isActive = (path: string) => { if (path === '/') { - return location.pathname === '/' || location.pathname.startsWith('/workflows'); + return location.pathname === '/'; + } + if (path === '/workflows') { + return location.pathname.startsWith('/workflows'); } return location.pathname === path || location.pathname.startsWith(`${path}/`); }; @@ -311,6 +339,23 @@ export function AppLayout({ children }: AppLayoutProps) { // Get page-specific actions const getPageActions = () => { if (location.pathname === '/') { + return ( + + ); + } + + if (location.pathname === '/workflows' || location.pathname.startsWith('/workflows')) { return ( + + {/* Search Button */} + + + )} + + {/* Search icon only when collapsed */} + {!sidebarOpen && ( +
+ +
+ )} + + {/* Navigation Items - Fixed */} +
{navigationItems.map((item) => { const Icon = item.icon; const active = isActive(item.href); @@ -404,17 +502,13 @@ export function AppLayout({ children }: AppLayoutProps) { key={item.href} to={item.href} onClick={(e) => { - // If modifier key is held (CMD+click, Ctrl+click), link opens in new tab - // Don't update sidebar state in this case if (e.metaKey || e.ctrlKey || e.shiftKey) { return; } - // Close sidebar on mobile after navigation if (isMobile) { setSidebarOpen(false); return; } - // Keep sidebar open when navigating to non-workflow routes (desktop) if (!item.href.startsWith('/workflows')) { setSidebarOpen(true); setWasExplicitlyOpened(true); @@ -447,38 +541,67 @@ export function AppLayout({ children }: AppLayoutProps) { })}
- {/* Command Palette Button */} -
- + + {isChatHistoryExpanded && ( +
+ {sessions.slice(0, 20).map((session) => ( +
+ + +
+ ))} +
)} - -
+ + )} @@ -554,8 +677,11 @@ export function AppLayout({ children }: AppLayoutProps) { isMobile ? 'w-full' : '', )} > - {/* Only show AppTopBar for non-workflow-builder and non-webhook-editor pages */} - {!location.pathname.startsWith('/workflows') && + {/* Show AppTopBar for: + - Non-chat pages (except workflow editor and webhook editor which have their own headers) + - Workflow list page (/workflows) to have "New Workflow" button */} + {!isChatInterface && + !(location.pathname.startsWith('/workflows/') && location.pathname !== '/workflows') && !location.pathname.startsWith('/webhooks/') && ( div { +.config-panel > div { scroll-behavior: smooth; } @@ -200,7 +208,7 @@ textarea { } .dark .status-success { - color: #4ADE80; + color: #4ade80; /* Accent Success */ } @@ -210,7 +218,7 @@ textarea { } .dark .status-error { - color: #EF4444; + color: #ef4444; /* Accent Error */ } @@ -220,7 +228,7 @@ textarea { } .dark .status-info { - color: #3C82F6; + color: #3c82f6; /* Accent Blue */ } @@ -230,7 +238,7 @@ textarea { } .dark .status-warning { - color: #FACC15; + color: #facc15; /* Accent Warning */ } @@ -267,7 +275,9 @@ textarea { /* Enhanced table row hover effects */ table tbody tr { - transition: background-color 0.15s ease, box-shadow 0.15s ease; + transition: + background-color 0.15s ease, + box-shadow 0.15s ease; } table tbody tr:hover { @@ -284,7 +294,9 @@ table tbody tr:hover { background-color: hsl(var(--card)) !important; border: 1px solid hsl(var(--border)) !important; border-radius: 0.375rem !important; - box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.2) !important; + box-shadow: + 0 4px 6px -1px rgb(0 0 0 / 0.3), + 0 2px 4px -2px rgb(0 0 0 / 0.2) !important; } .dark .react-flow__controls-button { @@ -315,7 +327,9 @@ table tbody tr:hover { background-color: hsl(var(--card)) !important; border: 1px solid hsl(var(--border)) !important; border-radius: 0.375rem !important; - box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.2) !important; + box-shadow: + 0 4px 6px -1px rgb(0 0 0 / 0.3), + 0 2px 4px -2px rgb(0 0 0 / 0.2) !important; } .dark .react-flow__minimap svg { @@ -467,7 +481,9 @@ table tbody tr:hover { border-radius: 2px; background: hsl(var(--border)); opacity: 0.6; - transition: opacity 0.15s, background-color 0.15s; + transition: + opacity 0.15s, + background-color 0.15s; } .timeline-resize-handle:hover::after { @@ -480,7 +496,11 @@ table tbody tr:hover { --text-node-handle-size: 28px; --text-node-handle-radius: var(--radius, 0.5rem); --text-node-handle-offset: calc((var(--text-node-handle-size) / 2) - 1px); - --text-node-handle-color-default: color-mix(in srgb, hsl(var(--border)) 70%, hsl(var(--foreground)) 30%); + --text-node-handle-color-default: color-mix( + in srgb, + hsl(var(--border)) 70%, + hsl(var(--foreground)) 30% + ); --text-node-handle-color-active: hsl(var(--primary)); --text-node-handle-color: var(--text-node-handle-color-default); --text-node-handle-opacity: 0.85; @@ -508,7 +528,10 @@ table tbody tr:hover { border: 2px solid transparent; opacity: var(--text-node-handle-opacity); pointer-events: none; - transition: border-color 0.15s ease, opacity 0.15s ease, transform 0.15s ease; + transition: + border-color 0.15s ease, + opacity 0.15s ease, + transform 0.15s ease; transform: scale(var(--text-node-handle-scale)); } @@ -547,11 +570,162 @@ table tbody tr:hover { .text-node-resize-line.react-flow__resize-control.line { border-color: transparent; opacity: 0; - transition: border-color 0.15s ease, opacity 0.15s ease; + transition: + border-color 0.15s ease, + opacity 0.15s ease; } .text-node-resize-line.react-flow__resize-control.line:hover, .text-node-resize-line.react-flow__resize-control.line:active { border-color: hsl(var(--primary)); opacity: 1; -} \ No newline at end of file +} + +/* ===== Chat Interface Styles ===== */ + +/* Chat scrollbar styling */ +.chat-scrollbar::-webkit-scrollbar { + width: 6px; +} + +.chat-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.chat-scrollbar::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; +} + +.chat-scrollbar::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); +} + +/* Chat message animations */ +@keyframes messageSlideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.chat-message-enter { + animation: messageSlideIn 0.3s ease-out forwards; +} + +/* Typing indicator animation */ +@keyframes typingPulse { + 0%, + 60%, + 100% { + opacity: 0.4; + } + 30% { + opacity: 1; + } +} + +.typing-dot { + animation: typingPulse 1.4s ease-in-out infinite; +} + +.typing-dot:nth-child(2) { + animation-delay: 0.2s; +} + +.typing-dot:nth-child(3) { + animation-delay: 0.4s; +} + +/* Smooth sidebar transitions */ +.chat-sidebar { + transition: + transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), + width 0.3s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.3s ease; +} + +/* Chat input focus glow */ +.chat-input-container:focus-within { + box-shadow: + 0 0 0 2px rgba(251, 146, 60, 0.1), + 0 4px 20px rgba(0, 0, 0, 0.3); +} + +/* Suggested prompt hover effects */ +.suggestion-pill { + transition: all 0.2s ease; +} + +.suggestion-pill:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +/* Command palette backdrop blur */ +.command-palette-overlay { + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +/* Workflow preview panel slide animation */ +@keyframes slideInFromRight { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.workflow-preview-enter { + animation: slideInFromRight 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +/* Chat history item hover state */ +.chat-history-item { + transition: + background-color 0.15s ease, + transform 0.15s ease; +} + +.chat-history-item:hover { + transform: translateX(2px); +} + +/* Gradient text for headings */ +.gradient-text { + background: linear-gradient(135deg, #fff 0%, rgba(255, 255, 255, 0.7) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* Smooth focus transitions for inputs */ +.chat-input { + transition: + box-shadow 0.2s ease, + border-color 0.2s ease; +} + +/* Avatar glow effect */ +.avatar-glow { + box-shadow: 0 0 20px rgba(251, 146, 60, 0.2); +} + +/* Message code block styling */ +.chat-message pre { + background: rgba(255, 255, 255, 0.03) !important; + border: 1px solid rgba(255, 255, 255, 0.08) !important; + border-radius: 12px !important; +} + +.chat-message code { + font-family: 'IBM Plex Mono', monospace; +} diff --git a/frontend/src/pages/ChatInterface.tsx b/frontend/src/pages/ChatInterface.tsx new file mode 100644 index 00000000..498b8f21 --- /dev/null +++ b/frontend/src/pages/ChatInterface.tsx @@ -0,0 +1,447 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { + Plus, + Sparkles, + Paperclip, + ArrowUp, + Command, + Bot, + User, + ChevronDown, + Loader2, + PanelLeftClose, + PanelLeftOpen, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { cn } from '@/lib/utils'; +import { Link } from 'react-router-dom'; +import { MarkdownView as Markdown } from '@/components/ui/markdown'; +import { ChatCommandPalette, WorkflowPreviewPanel, SuggestedPrompts } from '@/components/chat'; +import { useChatStore, type ChatMessage } from '@/store/chatStore'; +import { useAuth } from '@/auth/auth-context'; +import { useSidebar } from '@/components/layout/sidebar-context'; + +// Get time-based greeting +function getGreeting(): string { + const hour = new Date().getHours(); + if (hour < 12) return 'Good morning'; + if (hour < 18) return 'Good afternoon'; + return 'Good evening'; +} + +// Get user's first name or fallback +function getUserName(user: { email?: string; firstName?: string | null } | null): string { + if (!user) return 'there'; + if (user.firstName) return user.firstName; + if (user.email) return user.email.split('@')[0]; + return 'there'; +} + +// Message component +interface ChatMessageProps { + message: ChatMessage; +} + +function ChatMessageItem({ message }: ChatMessageProps) { + const isAssistant = message.role === 'assistant'; + const isLoading = message.metadata?.isLoading; + + return ( +
+ {/* Avatar */} + + {isAssistant ? ( + + + + ) : ( + + + + )} + + + {/* Message Content */} +
+
+ + {isAssistant ? 'ShipSec AI' : 'You'} + + + {new Date(message.timestamp).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + })} + +
+ +
+ {isLoading ? ( +
+ + Thinking... +
+ ) : ( + + )} +
+ + {message.metadata?.error && ( +
+ {message.metadata.error} +
+ )} +
+
+ ); +} + +// Empty state component +interface EmptyStateProps { + userName: string; + onSelectPrompt: (prompt: string) => void; +} + +function EmptyState({ userName, onSelectPrompt }: EmptyStateProps) { + return ( +
+ {/* Plan Badge */} +
+ + Free plan ·{' '} + + Upgrade + + +
+ + {/* Greeting */} +

+ + + + {getGreeting()}, {userName} + + +

+ +

+ How can I help you today? +

+ + {/* Suggested Prompts */} +
+ +
+
+ ); +} + +// Main Chat Interface +export function ChatInterface() { + const [input, setInput] = useState(''); + const [isTyping, setIsTyping] = useState(false); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + + const { user } = useAuth(); + const { + sessions, + currentSessionId, + createSession, + addMessage, + updateMessage, + getCurrentSession, + setCommandPaletteOpen, + } = useChatStore(); + + const currentSession = getCurrentSession(); + const messages = currentSession?.messages ?? []; + + // Auto-create session if none exists + useEffect(() => { + if (sessions.length === 0) { + createSession(); + } + }, [sessions.length, createSession]); + + // Scroll to bottom on new messages + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, []); + + useEffect(() => { + scrollToBottom(); + }, [messages, scrollToBottom]); + + // Auto-resize textarea + const handleInputChange = (e: React.ChangeEvent) => { + setInput(e.target.value); + // Auto-resize + e.target.style.height = 'auto'; + e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px'; + }; + + // Handle prompt selection + const handleSelectPrompt = useCallback((prompt: string) => { + setInput(prompt + ' '); + inputRef.current?.focus(); + }, []); + + // Send message + const handleSendMessage = useCallback( + (e?: React.FormEvent) => { + e?.preventDefault(); + + const trimmedInput = input.trim(); + if (!trimmedInput || !currentSessionId) return; + + // Add user message + addMessage(currentSessionId, { + role: 'user', + content: trimmedInput, + }); + + setInput(''); + if (inputRef.current) { + inputRef.current.style.height = 'auto'; + } + + // Add loading assistant message + addMessage(currentSessionId, { + role: 'assistant', + content: '', + metadata: { isLoading: true }, + }); + + // Simulate AI response (replace with actual API call) + setIsTyping(true); + setTimeout(() => { + // Get the last message and update it + const session = useChatStore.getState().sessions.find((s) => s.id === currentSessionId); + const lastMessage = session?.messages[session.messages.length - 1]; + + if (lastMessage && lastMessage.role === 'assistant') { + updateMessage(currentSessionId, lastMessage.id, { + content: generateMockResponse(trimmedInput), + metadata: { isLoading: false }, + }); + } + setIsTyping(false); + }, 1500); + }, + [input, currentSessionId, addMessage, updateMessage], + ); + + // Handle keyboard shortcuts + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Send on Enter (without Shift) + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + + // Open command palette on / + if (e.key === '/' && input === '') { + e.preventDefault(); + setCommandPaletteOpen(true); + } + }, + [handleSendMessage, input, setCommandPaletteOpen], + ); + + const { isOpen: sidebarOpen, toggle: toggleSidebar } = useSidebar(); + + return ( +
+ {/* Header with sidebar toggle */} +
+ +

AI Chat

+
+ + {/* Content Area */} +
+ {messages.length === 0 ? ( + + ) : ( +
+
+ {messages.map((msg) => ( + + ))} +
+
+
+ )} + + {/* Input Area */} +
+
+ {/* Input Container */} +
+
+