- {/* Auth components - UserButton includes organization switching */}
- {showUserButton && (
-
-
- {/* Dark mode toggle */}
+
+
+
+
+
+
+ {user?.firstName?.[0] || user?.email?.[0] || 'U'}
+
+
{sidebarOpen && (
-
- )}
- {/* Dark mode toggle when no user button */}
- {!showUserButton && (
-
+ {sidebarOpen && (
-
- )}
+ )}
+
@@ -554,8 +722,10 @@ export function AppLayout({ children }: AppLayoutProps) {
isMobile ? 'w-full' : '',
)}
>
- {/* Only show AppTopBar for non-workflow-builder and non-webhook-editor pages */}
- {!location.pathname.startsWith('/workflows') &&
+ {/* Only show AppTopBar for non-agent, non-workflow-builder, and non-webhook-editor pages */}
+ {location.pathname !== '/' &&
+ !location.pathname.startsWith('/c/') &&
+ !location.pathname.startsWith('/workflows') &&
!location.pathname.startsWith('/webhooks/') && (
= ({
+ language,
+ value,
+ showLineNumbers = true,
+}) => {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ console.error('Failed to copy code:', err);
+ }
+ }, [value]);
+
+ const displayLanguage = language === 'text' ? 'plaintext' : language;
+
+ // Determine theme based on document class
+ const isDark = document.documentElement.classList.contains('dark');
+ const syntaxTheme = isDark ? vscDarkPlus : vs;
+
+ return (
+
+
+
+ {displayLanguage}
+
+
+
+
+
+
+ {value}
+
+
+
+ );
+};
+
+export default CodeBlock;
diff --git a/frontend/src/components/ui/MoMADiagram.tsx b/frontend/src/components/ui/MoMADiagram.tsx
new file mode 100644
index 00000000..cf1b97fd
--- /dev/null
+++ b/frontend/src/components/ui/MoMADiagram.tsx
@@ -0,0 +1,463 @@
+import React, { useState, useRef, useEffect, useCallback } from 'react';
+import mermaid from 'mermaid';
+import { Copy, Download, Maximize2, ZoomIn, ZoomOut, RotateCcw, Loader2 } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+// Initialize Mermaid once
+let mermaidInitialized = false;
+
+const initializeMermaid = () => {
+ if (mermaidInitialized) return;
+
+ mermaid.initialize({
+ startOnLoad: false,
+ theme: 'base',
+ themeVariables: {
+ primaryColor: '#3C82F6',
+ primaryTextColor: '#FFFFFF',
+ primaryBorderColor: '#2563EB',
+ lineColor: '#7A7C80',
+ secondaryColor: '#F3F4F6',
+ tertiaryColor: '#E5E7EB',
+ background: '#1C1C1C',
+ mainBkg: '#232427',
+ nodeBorder: '#2A2B2D',
+ clusterBkg: '#232427',
+ clusterBorder: '#2A2B2D',
+ titleColor: '#FFFFFF',
+ edgeLabelBackground: '#1F2023',
+ actorBkg: '#232427',
+ actorBorder: '#2A2B2D',
+ actorLineColor: '#7A7C80',
+ signalColor: '#7A7C80',
+ signalTextColor: '#C6C7C8',
+ labelBoxBkgColor: '#1F2023',
+ labelBoxBorderColor: '#2A2B2D',
+ labelTextColor: '#C6C7C8',
+ loopTextColor: '#C6C7C8',
+ noteBorderColor: '#2A2B2D',
+ noteTextColor: '#C6C7C8',
+ message0: '#3C82F6',
+ message1: '#10B981',
+ message2: '#F59E0B',
+ message3: '#EF4444',
+ message4: '#8B5CF6',
+ message5: '#EC4899',
+ message6: '#14B8A6',
+ message7: '#F97316',
+ },
+ securityLevel: 'loose',
+ fontFamily: 'DM Sans, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif',
+ fontSize: 14,
+ flowchart: {
+ curve: 'basis',
+ padding: 20,
+ nodeSpacing: 50,
+ rankSpacing: 50,
+ useMaxWidth: true,
+ },
+ sequence: {
+ diagramMarginX: 50,
+ diagramMarginY: 10,
+ actorMargin: 50,
+ width: 150,
+ height: 65,
+ boxMargin: 10,
+ boxTextMargin: 5,
+ noteMargin: 10,
+ messageMargin: 35,
+ useMaxWidth: true,
+ },
+ });
+ mermaidInitialized = true;
+};
+
+export interface MoMADiagramProps {
+ code: string;
+ className?: string;
+ onEdit?: (code: string) => void;
+}
+
+type ViewState = 'idle' | 'loading' | 'rendered' | 'error';
+
+interface Transform {
+ scale: number;
+ translateX: number;
+ translateY: number;
+}
+
+export const MoMADiagram = React.forwardRef(
+ ({ code, className, onEdit }, ref) => {
+ const [viewState, setViewState] = useState('idle');
+ const [errorMessage, setErrorMessage] = useState('');
+ const [svgContent, setSvgContent] = useState('');
+ const [transform, setTransform] = useState({
+ scale: 1,
+ translateX: 0,
+ translateY: 0,
+ });
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
+ const [showCopyFeedback, setShowCopyFeedback] = useState(false);
+
+ const containerRef = useRef(null);
+ const svgRef = useRef(null);
+ const diagramIdRef = useRef(`mermaid-${Math.random().toString(36).substr(2, 9)}`);
+
+ // Initialize Mermaid on mount
+ useEffect(() => {
+ initializeMermaid();
+ }, []);
+
+ // Parse and render diagram when code changes
+ useEffect(() => {
+ if (!code.trim()) {
+ setViewState('idle');
+ return;
+ }
+
+ const renderDiagram = async () => {
+ setViewState('loading');
+ setErrorMessage('');
+
+ try {
+ // Ensure Mermaid is initialized
+ initializeMermaid();
+
+ const uniqueId = `mermaid-${Math.random().toString(36).substr(2, 9)}`;
+ diagramIdRef.current = uniqueId;
+
+ // Parse and render the diagram
+ const { svg } = await mermaid.render(uniqueId, code);
+ setSvgContent(svg);
+ setViewState('rendered');
+
+ // Reset transform when new diagram renders
+ setTransform({ scale: 1, translateX: 0, translateY: 0 });
+ } catch (error) {
+ console.error('Mermaid rendering error:', error);
+ setErrorMessage(error instanceof Error ? error.message : 'Failed to render diagram');
+ setViewState('error');
+ }
+ };
+
+ const debounceTimer = setTimeout(renderDiagram, 150);
+ return () => clearTimeout(debounceTimer);
+ }, [code]);
+
+ // Handle wheel zoom
+ const handleWheel = useCallback(
+ (e: React.WheelEvent) => {
+ e.preventDefault();
+ const delta = e.deltaY > 0 ? -0.1 : 0.1;
+ const newScale = Math.min(Math.max(transform.scale + delta, 0.1), 5);
+ setTransform((prev) => ({ ...prev, scale: newScale }));
+ },
+ [transform.scale],
+ );
+
+ // Handle pan drag start
+ const handleMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ if (e.button !== 0) return; // Only left mouse button
+ setIsDragging(true);
+ setDragStart({ x: e.clientX - transform.translateX, y: e.clientY - transform.translateY });
+ },
+ [transform],
+ );
+
+ // Handle pan drag move
+ const handleMouseMove = useCallback(
+ (e: React.MouseEvent) => {
+ if (!isDragging) return;
+ e.preventDefault();
+ setTransform({
+ ...transform,
+ translateX: e.clientX - dragStart.x,
+ translateY: e.clientY - dragStart.y,
+ });
+ },
+ [isDragging, dragStart, transform],
+ );
+
+ // Handle pan drag end
+ const handleMouseUp = useCallback(() => {
+ setIsDragging(false);
+ }, []);
+
+ // Handle global mouse up to catch drag releases outside container
+ useEffect(() => {
+ const handleGlobalMouseUp = () => setIsDragging(false);
+ window.addEventListener('mouseup', handleGlobalMouseUp);
+ return () => window.removeEventListener('mouseup', handleGlobalMouseUp);
+ }, []);
+
+ // Zoom controls
+ const handleZoomIn = useCallback(() => {
+ setTransform((prev) => ({ ...prev, scale: Math.min(prev.scale + 0.2, 5) }));
+ }, []);
+
+ const handleZoomOut = useCallback(() => {
+ setTransform((prev) => ({ ...prev, scale: Math.max(prev.scale - 0.2, 0.1) }));
+ }, []);
+
+ const handleResetView = useCallback(() => {
+ setTransform({ scale: 1, translateX: 0, translateY: 0 });
+ }, []);
+
+ const handleFitToScreen = useCallback(() => {
+ if (!containerRef.current || !svgRef.current) return;
+
+ const container = containerRef.current;
+ const svg = svgRef.current.firstElementChild;
+
+ if (!svg) return;
+
+ const containerRect = container.getBoundingClientRect();
+ const svgRect = svg.getBoundingClientRect();
+
+ const padding = 40;
+ const scaleX = (containerRect.width - padding) / svgRect.width;
+ const scaleY = (containerRect.height - padding) / svgRect.height;
+ const newScale = Math.min(scaleX, scaleY, 1);
+
+ setTransform({ scale: newScale, translateX: 0, translateY: 0 });
+ }, []);
+
+ // Copy code to clipboard
+ const handleCopyCode = useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(code);
+ setShowCopyFeedback(true);
+ setTimeout(() => setShowCopyFeedback(false), 2000);
+ } catch (error) {
+ console.error('Failed to copy code:', error);
+ }
+ }, [code]);
+
+ // Download SVG
+ const handleDownloadSVG = useCallback(() => {
+ if (!svgContent) return;
+
+ const blob = new Blob([svgContent], { type: 'image/svg+xml' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `diagram-${Date.now()}.svg`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ }, [svgContent]);
+
+ return (
+ {
+ // Handle both refs
+ if (typeof ref === 'function') ref(node);
+ else if (ref) ref.current = node;
+ containerRef.current = node;
+ }}
+ className={cn(
+ 'relative overflow-hidden rounded-lg border bg-card',
+ 'shadow-sm transition-shadow duration-200',
+ 'hover:shadow-md',
+ className,
+ )}
+ style={{ height: '100%', minHeight: '300px' }}
+ >
+ {/* Toolbar */}
+
+
+ Diagram
+
+
+ {/* Zoom controls */}
+
+
+
+
+
+
+
+ {/* Action buttons */}
+
+
+
+
+
+ {/* Diagram container */}
+
+
+ {viewState === 'loading' && (
+
+
+ Rendering diagram...
+
+ )}
+
+ {viewState === 'error' && (
+
+
+
+
Diagram Error
+
+ {errorMessage || 'Unable to render the diagram. Please check the syntax.'}
+
+
+ {onEdit && (
+
+ )}
+
+ )}
+
+ {viewState === 'rendered' && svgContent && (
+
+ )}
+
+ {viewState === 'idle' && (
+
+
+
No diagram to display
+
+ )}
+
+
+
+ {/* Zoom indicator */}
+ {transform.scale !== 1 && (
+
+ {Math.round(transform.scale * 100)}%
+
+ )}
+
+ );
+ },
+);
+
+MoMADiagram.displayName = 'MoMADiagram';
+
+export default MoMADiagram;
+
+/**
+ * Utility function to extract Mermaid/MoMA diagram code from markdown text.
+ * Supports both ```mermaid and ```moma code blocks.
+ */
+export function extractDiagramCode(text: string): string | null {
+ // Try to match ```moma or ```mermaid code blocks
+ const mermaidRegex = /```(?:moma|mermaid)\n([\s\S]*?)```/i;
+ const match = text.match(mermaidRegex);
+
+ if (match && match[1]) {
+ return match[1].trim();
+ }
+
+ return null;
+}
+
+/**
+ * Utility function to extract all Mermaid/MoMA diagrams from markdown text.
+ * Returns an array of diagram code strings.
+ */
+export function extractAllDiagrams(text: string): string[] {
+ const mermaidRegex = /```(?:moma|mermaid)\n([\s\S]*?)```/gi;
+ const matches: string[] = [];
+ let match;
+
+ while ((match = mermaidRegex.exec(text)) !== null) {
+ if (match[1]) {
+ matches.push(match[1].trim());
+ }
+ }
+
+ return matches;
+}
diff --git a/frontend/src/components/ui/__tests__/markdown.test.tsx b/frontend/src/components/ui/__tests__/markdown.test.tsx
index 8f2c726d..7c8e6c9f 100644
--- a/frontend/src/components/ui/__tests__/markdown.test.tsx
+++ b/frontend/src/components/ui/__tests__/markdown.test.tsx
@@ -31,7 +31,13 @@ describe('MarkdownView', () => {
it('renders code blocks', () => {
const markdown = '```js\nconst x = 1\n```';
- render();
- expect(screen.getByText(/const x = 1/)).toBeInTheDocument();
+ const { container } = render();
+ // Syntax highlighting splits code into tokens, so check for individual parts
+ expect(screen.getByText('const')).toBeInTheDocument();
+ expect(screen.getByText('x')).toBeInTheDocument();
+ // Check that the CodeBlock component wrapper is rendered
+ expect(container.querySelector('.rounded-lg.border')).toBeInTheDocument();
+ // Verify the language label is shown
+ expect(screen.getByText('js')).toBeInTheDocument();
});
});
diff --git a/frontend/src/components/ui/markdown.tsx b/frontend/src/components/ui/markdown.tsx
index a1bd0091..63a70835 100644
--- a/frontend/src/components/ui/markdown.tsx
+++ b/frontend/src/components/ui/markdown.tsx
@@ -5,6 +5,7 @@ import markdownItTaskLists from 'markdown-it-task-lists';
import markdownItHTML5Embed from 'markdown-it-html5-embed';
import markdownItImsize from '@/lib/markdown-it-imsize';
import { cn } from '@/lib/utils';
+import { CodeBlock } from './CodeBlock';
interface MarkdownViewProps {
content: string;
@@ -13,6 +14,7 @@ interface MarkdownViewProps {
// When provided, enables interactive task checkboxes and will be called
// with the updated markdown string after a toggle.
onEdit?: (next: string) => void;
+ showLineNumbers?: boolean;
}
// Initialize markdown-it with plugins (similar to n8n sticky notes)
@@ -67,6 +69,59 @@ function toggleNthTask(md: string, index: number): string {
// Key: dataTestId, Value: expected content string
const pendingCheckboxUpdates = new Map();
+// Code block renderer with syntax highlighting
+const renderCodeBlocks = (html: string, showLineNumbers = true): React.ReactNode => {
+ const codeBlockRegex = /(.*?)<\/code><\/pre>/gs;
+ const blocks: React.ReactNode[] = [];
+ let lastIndex = 0;
+ let match;
+
+ while ((match = codeBlockRegex.exec(html)) !== null) {
+ // Add text before this code block
+ if (match.index > lastIndex) {
+ blocks.push(
+ ,
+ );
+ }
+
+ const language = match[1];
+ const code = match[2]
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'");
+
+ blocks.push(
+ ,
+ );
+
+ lastIndex = match.index + match[0].length;
+ }
+
+ // Add remaining text after last code block
+ if (lastIndex < html.length) {
+ blocks.push(
+ ,
+ );
+ }
+
+ // If no code blocks found, return original HTML
+ if (blocks.length === 0) {
+ return ;
+ }
+
+ return <>{blocks}>;
+};
+
// Custom comparison for memo - only re-render when content/className/dataTestId change
// Ignore onEdit since it's stored in a ref and changes every parent render
function arePropsEqual(prevProps: MarkdownViewProps, nextProps: MarkdownViewProps): boolean {
@@ -88,7 +143,8 @@ function arePropsEqual(prevProps: MarkdownViewProps, nextProps: MarkdownViewProp
const equal =
prevProps.content === nextProps.content &&
prevProps.className === nextProps.className &&
- prevProps.dataTestId === nextProps.dataTestId;
+ prevProps.dataTestId === nextProps.dataTestId &&
+ prevProps.showLineNumbers === nextProps.showLineNumbers;
if (!equal) {
console.log('[MarkdownView] Props changed, will re-render');
}
@@ -102,6 +158,7 @@ export const MarkdownView = memo(function MarkdownView({
className,
dataTestId,
onEdit,
+ showLineNumbers = true,
}: MarkdownViewProps) {
console.log('[MarkdownView] Rendering with content length:', content.length);
// Store onEdit in a ref so we can use a stable callback without re-renders
@@ -122,6 +179,12 @@ export const MarkdownView = memo(function MarkdownView({
return rendered.replace(/(]*type="checkbox"[^>]*)disabled([^>]*>)/g, '$1$2');
}, [normalized]);
+ // Render with code block highlighting
+ const renderedContent = useMemo(
+ () => renderCodeBlocks(html, showLineNumbers),
+ [html, showLineNumbers],
+ );
+
// Handle clicks on interactive elements - use useCallback for stable reference
const handleClick = useCallback((e: React.MouseEvent) => {
const target = e.target as HTMLElement;
@@ -216,8 +279,9 @@ export const MarkdownView = memo(function MarkdownView({
onMouseDownCapture={handleMouseDown}
onClick={handleClick}
onWheel={handleWheel}
- dangerouslySetInnerHTML={{ __html: html }}
- />
+ >
+ {renderedContent}
+
);
}, arePropsEqual);
diff --git a/frontend/src/pages/AgentPage.tsx b/frontend/src/pages/AgentPage.tsx
new file mode 100644
index 00000000..b06859d6
--- /dev/null
+++ b/frontend/src/pages/AgentPage.tsx
@@ -0,0 +1,1980 @@
+import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import {
+ Send,
+ ArrowUp,
+ Sparkles,
+ Workflow,
+ Shield,
+ FileSearch,
+ Zap,
+ User,
+ GitBranch,
+ AlertTriangle,
+ ChevronRight,
+ Clock,
+ Loader2,
+ Play,
+ Bug,
+ Lock,
+ CloudCog,
+ Database,
+ Terminal,
+ PanelLeftOpen,
+ PanelLeftClose,
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { cn } from '@/lib/utils';
+import { useChatStore, type ChatMessage } from '@/store/chatStore';
+import { useAuthProvider } from '@/auth/auth-context';
+import { MarkdownView } from '@/components/ui/markdown';
+import { StatusBar } from '@/components/agent/StatusBar';
+import { MoMADiagram } from '@/components/ui/MoMADiagram';
+import { useSidebar } from '@/components/layout/sidebar-context';
+
+// GitHub icon component
+function GitHubIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+const suggestedActions = [
+ {
+ icon: Workflow,
+ label: 'Run workflow',
+ description: 'Execute a security workflow',
+ },
+ {
+ icon: Shield,
+ label: 'Scan repository',
+ description: 'Analyze code for vulnerabilities',
+ },
+ {
+ icon: FileSearch,
+ label: 'Review findings',
+ description: 'Check recent security findings',
+ },
+ {
+ icon: AlertTriangle,
+ label: 'Investigate alert',
+ description: 'Investigate security alerts',
+ },
+];
+
+// Types for rich message content
+type MessageContentType =
+ | { type: 'text'; content: string }
+ | { type: 'thinking'; content: string }
+ | { type: 'workflow-buttons'; workflows: WorkflowOption[] }
+ | { type: 'repo-buttons'; repos: RepoOption[]; intro: string }
+ | { type: 'finding-cards'; findings: FindingOption[] }
+ | { type: 'quick-action-buttons'; actions: QuickActionOption[] }
+ | { type: 'guardduty-alerts'; alerts: GuardDutyAlert[] }
+ | { type: 'action-buttons'; buttons: ActionButton[] }
+ | { type: 'loading'; content: string }
+ | { type: 'moma-diagram'; code: string };
+
+interface WorkflowOption {
+ id: string;
+ name: string;
+ description: string;
+ icon: React.ElementType;
+ status: 'active' | 'scheduled' | 'draft';
+}
+
+interface RepoOption {
+ id: string;
+ name: string;
+ org: string;
+ lastScanned?: string;
+ isRecent?: boolean;
+}
+
+interface FindingOption {
+ id: string;
+ source: string;
+ severity: 'critical' | 'high' | 'medium' | 'low';
+ count: number;
+ lastRun: string;
+ icon: React.ElementType;
+}
+
+interface QuickActionOption {
+ id: string;
+ name: string;
+ description: string;
+ icon: React.ElementType;
+}
+
+interface GuardDutyAlert {
+ id: string;
+ title: string;
+ severity: 'critical' | 'high' | 'medium' | 'low';
+ timestamp: string;
+ instance: string;
+ sourceIp: string;
+ description: string;
+}
+
+interface ActionButton {
+ id: string;
+ label: string;
+ emoji: string;
+ variant: 'primary' | 'destructive';
+}
+
+// Extended message type for rich content
+interface RichChatMessage extends Omit
{
+ content: string | MessageContentType[];
+ isStreaming?: boolean;
+}
+
+// Module-level cache for rich messages across component remounts
+// (React Router remounts AgentPage when switching between "/" and "/c/:id" routes)
+const richMessagesByConversation = new Map();
+
+// Collapsible thinking section component (like ChatGPT)
+function ThinkingSection({ content, isActive }: { content: string; isActive: boolean }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [elapsed, setElapsed] = useState(0);
+ const startTimeRef = useRef(Date.now());
+ const frozenElapsedRef = useRef(null);
+ const wasEverActiveRef = useRef(isActive);
+
+ useEffect(() => {
+ if (isActive) {
+ wasEverActiveRef.current = true;
+ startTimeRef.current = Date.now();
+ frozenElapsedRef.current = null;
+ const interval = setInterval(() => {
+ setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
+ }, 100);
+ return () => clearInterval(interval);
+ } else if (wasEverActiveRef.current && frozenElapsedRef.current === null) {
+ frozenElapsedRef.current = Math.max(
+ 1,
+ Math.floor((Date.now() - startTimeRef.current) / 1000),
+ );
+ setElapsed(frozenElapsedRef.current);
+ }
+ }, [isActive]);
+
+ if (isActive) {
+ return (
+
+
+ Thinking
+ {elapsed > 0 && {elapsed}s}
+
+ );
+ }
+
+ const displaySeconds = frozenElapsedRef.current || Math.max(1, elapsed);
+ const durationLabel = wasEverActiveRef.current
+ ? `Thought for ${displaySeconds} second${displaySeconds !== 1 ? 's' : ''}`
+ : 'Thought for a moment';
+
+ return (
+
+
+ {isOpen && (
+
+ )}
+
+ );
+}
+
+// Workflow button component
+function WorkflowButton({
+ workflow,
+ onClick,
+}: {
+ workflow: WorkflowOption;
+ onClick: (workflow: WorkflowOption) => void;
+}) {
+ const Icon = workflow.icon;
+ const statusColors = {
+ active: 'bg-green-500/10 text-green-600 border-green-200',
+ scheduled: 'bg-blue-500/10 text-blue-600 border-blue-200',
+ draft: 'bg-gray-500/10 text-gray-600 border-gray-200',
+ };
+
+ return (
+
+ );
+}
+
+// Repository button component
+function RepoButton({ repo, onClick }: { repo: RepoOption; onClick: (repo: RepoOption) => void }) {
+ return (
+
+ );
+}
+
+// Finding card component
+function FindingCard({
+ finding,
+ onClick,
+}: {
+ finding: FindingOption;
+ onClick: (finding: FindingOption) => void;
+}) {
+ const Icon = finding.icon;
+ const severityColors = {
+ critical: 'bg-red-500/10 text-red-600 border-red-200',
+ high: 'bg-orange-500/10 text-orange-600 border-orange-200',
+ medium: 'bg-yellow-500/10 text-yellow-600 border-yellow-200',
+ low: 'bg-blue-500/10 text-blue-600 border-blue-200',
+ };
+
+ return (
+
+ );
+}
+
+// Quick action button component
+function QuickActionButton({
+ action,
+ onClick,
+}: {
+ action: QuickActionOption;
+ onClick: (action: QuickActionOption) => void;
+}) {
+ const Icon = action.icon;
+
+ return (
+
+ );
+}
+
+// GuardDuty alert card component
+function GuardDutyAlertCard({
+ alert,
+ onClick,
+}: {
+ alert: GuardDutyAlert;
+ onClick: (alert: GuardDutyAlert) => void;
+}) {
+ const severityColors = {
+ critical: 'bg-red-500/10 text-red-600 border-red-200',
+ high: 'bg-orange-500/10 text-orange-600 border-orange-200',
+ medium: 'bg-yellow-500/10 text-yellow-600 border-yellow-200',
+ low: 'bg-blue-500/10 text-blue-600 border-blue-200',
+ };
+
+ return (
+
+ );
+}
+
+// Action button group component
+function ActionButtonGroup({
+ buttons,
+ onClick,
+}: {
+ buttons: ActionButton[];
+ onClick: (button: ActionButton) => void;
+}) {
+ return (
+
+ {buttons.map((button) => (
+
+ ))}
+
+ );
+}
+
+// Rich message content renderer
+function RichMessageContent({
+ content,
+ onWorkflowClick,
+ onRepoClick,
+ onFindingClick,
+ onQuickActionClick,
+ onAlertClick,
+ onActionButtonClick,
+}: {
+ content: MessageContentType[];
+ onWorkflowClick: (workflow: WorkflowOption) => void;
+ onRepoClick: (repo: RepoOption) => void;
+ onFindingClick: (finding: FindingOption) => void;
+ onQuickActionClick: (action: QuickActionOption) => void;
+ onAlertClick: (alert: GuardDutyAlert) => void;
+ onActionButtonClick: (button: ActionButton) => void;
+}) {
+ return (
+
+ {content.map((item, index) => {
+ switch (item.type) {
+ case 'text':
+ return (
+
+ );
+ case 'thinking':
+ return (
+
+ );
+ case 'loading':
+ return (
+
+
+ {item.content}
+
+ );
+ case 'workflow-buttons':
+ return (
+
+ {item.workflows.map((workflow) => (
+
+ ))}
+
+ );
+ case 'repo-buttons':
+ return (
+
+
{item.intro}
+
+ {item.repos.map((repo) => (
+
+ ))}
+
+
+ );
+ case 'finding-cards':
+ return (
+
+ {item.findings.map((finding) => (
+
+ ))}
+
+ );
+ case 'quick-action-buttons':
+ return (
+
+ {item.actions.map((action) => (
+
+ ))}
+
+ );
+ case 'guardduty-alerts':
+ return (
+
+ {item.alerts.map((alert) => (
+
+ ))}
+
+ );
+ case 'action-buttons':
+ return (
+
+ );
+ default:
+ return null;
+ }
+ })}
+
+ );
+}
+
+interface MessageBubbleProps {
+ message: RichChatMessage;
+ userImageUrl?: string;
+ userInitials?: string;
+ onWorkflowClick: (workflow: WorkflowOption) => void;
+ onRepoClick: (repo: RepoOption) => void;
+ onFindingClick: (finding: FindingOption) => void;
+ onQuickActionClick: (action: QuickActionOption) => void;
+ onAlertClick: (alert: GuardDutyAlert) => void;
+ onActionButtonClick: (button: ActionButton) => void;
+}
+
+function MessageBubble({
+ message,
+ userImageUrl,
+ userInitials,
+ onWorkflowClick,
+ onRepoClick,
+ onFindingClick,
+ onQuickActionClick,
+ onAlertClick,
+ onActionButtonClick,
+}: MessageBubbleProps) {
+ const isUser = message.role === 'user';
+ const isRichContent = Array.isArray(message.content);
+
+ // Extract diagrams from rich content to render outside the bubble
+ const contentWithDiagramsExtracted = useMemo(() => {
+ if (!isRichContent)
+ return {
+ bubbleContent: message.content as string | MessageContentType[],
+ diagrams: [] as MessageContentType[],
+ };
+
+ const content = message.content as MessageContentType[];
+ const diagrams: MessageContentType[] = [];
+ const bubbleContent: MessageContentType[] = [];
+
+ content.forEach((item) => {
+ if (item.type === 'moma-diagram') {
+ diagrams.push(item);
+ } else {
+ bubbleContent.push(item);
+ }
+ });
+
+ return { bubbleContent, diagrams };
+ }, [isRichContent, message.content]);
+
+ return (
+
+ {/* Chat bubble */}
+
+ {/* Assistant avatar (left side) */}
+ {!isUser && (
+
+
+
+ AI
+
+
+ )}
+
+
+ {isRichContent ? (
+
+ ) : (
+
+ )}
+
+ {message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+
+
+
+ {/* User avatar (right side) */}
+ {isUser && (
+
+
+
+ {userInitials || }
+
+
+ )}
+
+
+ {/* Render diagrams outside the bubble, full width */}
+ {contentWithDiagramsExtracted.diagrams.map((item: MessageContentType, index: number) => (
+
+ {item.type === 'moma-diagram' && }
+
+ ))}
+
+ );
+}
+
+function WelcomeScreen({ onSuggestedAction }: { onSuggestedAction: (action: string) => void }) {
+ return (
+
+ {/* Logo and branding */}
+
+
+

{
+ e.currentTarget.style.display = 'none';
+ }}
+ />
+
+
ShipSec AI Agent
+
+ Your intelligent security assistant
+
+
+
+ {/*
+
+ Powered by Claude Opus
+
*/}
+
+
+ {/* Suggested actions */}
+
+ {suggestedActions.map((action) => {
+ const Icon = action.icon;
+ return (
+
+ );
+ })}
+
+
+ {/* Metadata footer */}
+
+
+ ShipSec AI can help you with security workflows, code scanning, and vulnerability
+ management.
+
+
+
+ );
+}
+
+// Mock data for workflows
+const mockWorkflows: WorkflowOption[] = [
+ {
+ id: 'w1',
+ name: 'SAST Code Analysis',
+ description: 'Static application security testing with Semgrep',
+ icon: Bug,
+ status: 'active',
+ },
+ {
+ id: 'w2',
+ name: 'Dependency Audit',
+ description: 'Check for vulnerable dependencies in your codebase',
+ icon: GitBranch,
+ status: 'active',
+ },
+ {
+ id: 'w3',
+ name: 'Secret Scanner',
+ description: 'Detect hardcoded secrets and API keys',
+ icon: Lock,
+ status: 'active',
+ },
+ {
+ id: 'w4',
+ name: 'Infrastructure Review',
+ description: 'Analyze IaC templates for misconfigurations',
+ icon: CloudCog,
+ status: 'scheduled',
+ },
+ {
+ id: 'w5',
+ name: 'Container Security',
+ description: 'Scan Docker images for vulnerabilities',
+ icon: Database,
+ status: 'draft',
+ },
+];
+
+// Mock data for repositories
+const mockRepos: RepoOption[] = [
+ {
+ id: 'r1',
+ name: 'studio',
+ org: 'ShipSecAI',
+ lastScanned: '2 hours ago',
+ isRecent: true,
+ },
+ {
+ id: 'r2',
+ name: 'api-gateway',
+ org: 'ShipSecAI',
+ lastScanned: '1 day ago',
+ },
+ {
+ id: 'r3',
+ name: 'auth-service',
+ org: 'ShipSecAI',
+ lastScanned: '3 days ago',
+ },
+ {
+ id: 'r4',
+ name: 'frontend-app',
+ org: 'ShipSecAI',
+ lastScanned: '1 week ago',
+ },
+];
+
+// Mock data for findings
+const mockFindings: FindingOption[] = [
+ {
+ id: 'f1',
+ source: 'AWS Security Hub',
+ severity: 'critical',
+ count: 3,
+ lastRun: 'Today, 2:30 PM',
+ icon: CloudCog,
+ },
+ {
+ id: 'f2',
+ source: 'GitHub Advanced Security',
+ severity: 'high',
+ count: 12,
+ lastRun: 'Today, 11:00 AM',
+ icon: GitBranch,
+ },
+ {
+ id: 'f3',
+ source: 'Semgrep SAST',
+ severity: 'medium',
+ count: 28,
+ lastRun: 'Yesterday, 4:15 PM',
+ icon: Bug,
+ },
+ {
+ id: 'f4',
+ source: 'Trivy Container Scan',
+ severity: 'high',
+ count: 7,
+ lastRun: 'Yesterday, 9:00 AM',
+ icon: Database,
+ },
+ {
+ id: 'f5',
+ source: 'Checkov IaC Analysis',
+ severity: 'low',
+ count: 45,
+ lastRun: '2 days ago',
+ icon: Terminal,
+ },
+];
+
+// Dynamic date helpers for realistic mock data
+function formatAlertTimestamp(date: Date, hours: number, minutes: number): string {
+ const y = date.getUTCFullYear();
+ const m = String(date.getUTCMonth() + 1).padStart(2, '0');
+ const d = String(date.getUTCDate()).padStart(2, '0');
+ return `${y}-${m}-${d} ${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')} UTC`;
+}
+
+function formatDateOnly(date: Date): string {
+ const y = date.getUTCFullYear();
+ const m = String(date.getUTCMonth() + 1).padStart(2, '0');
+ const d = String(date.getUTCDate()).padStart(2, '0');
+ return `${y}-${m}-${d}`;
+}
+
+function formatDateCompact(date: Date): string {
+ const y = date.getUTCFullYear();
+ const m = String(date.getUTCMonth() + 1).padStart(2, '0');
+ const d = String(date.getUTCDate()).padStart(2, '0');
+ return `${y}${m}${d}`;
+}
+
+const _now = new Date();
+const _today = new Date(_now);
+const _yesterday = new Date(_now);
+_yesterday.setDate(_yesterday.getDate() - 1);
+
+const ALERT_DATE_PRIMARY = formatAlertTimestamp(_today, 6, 22);
+const ALERT_DATE_SECONDARY = formatAlertTimestamp(_yesterday, 18, 47);
+const ALERT_DATE_TERTIARY = formatAlertTimestamp(_yesterday, 14, 30);
+
+const _ptoStart = new Date(_today);
+_ptoStart.setDate(_ptoStart.getDate() - 5);
+const _ptoEnd = new Date(_today);
+_ptoEnd.setDate(_ptoEnd.getDate() + 1);
+const PTO_START = formatDateOnly(_ptoStart);
+const PTO_END = formatDateOnly(_ptoEnd);
+const TODAY_COMPACT = formatDateCompact(_today);
+
+// Mock data for GuardDuty alerts
+const mockGuardDutyAlerts: GuardDutyAlert[] = [
+ {
+ id: 'gd1',
+ title: 'Unusual SSH Login Detected',
+ severity: 'high',
+ timestamp: ALERT_DATE_PRIMARY,
+ instance: 'i-07abc123d456ef789',
+ sourceIp: '189.45.23.18',
+ description: 'Suspicious SSH login from unauthorized IP (GeoIP: São Paulo, Brazil)',
+ },
+ {
+ id: 'gd3',
+ title: 'IAM Credentials Exfiltration Attempt',
+ severity: 'high',
+ timestamp: ALERT_DATE_SECONDARY,
+ instance: 'i-09f8e7d6c5b4a3210',
+ sourceIp: '103.21.244.0',
+ description: 'Unusual API call pattern detected from temporary credentials',
+ },
+ {
+ id: 'gd4',
+ title: 'S3 Bucket Brute Force Access',
+ severity: 'medium',
+ timestamp: ALERT_DATE_TERTIARY,
+ instance: 'N/A',
+ sourceIp: '198.51.100.42',
+ description: 'Multiple failed access attempts on private S3 buckets',
+ },
+];
+
+export function AgentPage() {
+ const { conversationId: urlConversationId } = useParams<{ conversationId: string }>();
+ const navigate = useNavigate();
+ const [inputValue, setInputValue] = useState('');
+ const [richMessages, setRichMessages] = useState([]);
+ const messagesEndRef = useRef(null);
+ const textareaRef = useRef(null);
+
+ // Sidebar toggle from AppLayout context
+ const { isOpen: sidebarOpen, toggle: toggleSidebar, isMobile } = useSidebar();
+
+ const authProvider = useAuthProvider();
+ const { user } = authProvider.context;
+
+ // Get user avatar info from Clerk authentication
+ const userImageUrl = user?.imageUrl;
+ const userInitials =
+ user?.firstName && user?.lastName
+ ? `${user.firstName[0]}${user.lastName[0]}`
+ : user?.username
+ ? user.username.substring(0, 2).toUpperCase()
+ : user?.email
+ ? user.email.substring(0, 2).toUpperCase()
+ : undefined;
+
+ const {
+ activeConversationId,
+ createConversation,
+ addMessage,
+ setMessages,
+ getActiveConversation,
+ setActiveConversation,
+ conversations,
+ } = useChatStore();
+
+ // Refs for tracking conversation ownership across effect cycles
+ const richMessagesRef = useRef([]);
+ const activeConvIdRef = useRef(null);
+ const syncTimerRef = useRef | undefined>(undefined);
+
+ // Sync URL param with active conversation (only when navigating to /c/:id directly)
+ // Don't clear activeConversation when urlConversationId is absent — the "New Chat"
+ // handlers in AgentLayout/AppLayout handle that explicitly via setActiveConversation(null).
+ useEffect(() => {
+ if (urlConversationId) {
+ const exists = conversations.some((c) => c.id === urlConversationId);
+ if (exists) {
+ setActiveConversation(urlConversationId);
+ } else {
+ navigate('/', { replace: true });
+ }
+ }
+ }, [urlConversationId, conversations, setActiveConversation, navigate]);
+
+ // Keep richMessagesRef in sync and save to module cache on every change.
+ // Also debounce-sync text content to the chatStore for cross-refresh persistence.
+ useEffect(() => {
+ richMessagesRef.current = richMessages;
+ if (activeConvIdRef.current && richMessages.length > 0) {
+ // Immediately save to module-level cache (preserves rich components)
+ richMessagesByConversation.set(activeConvIdRef.current, [...richMessages]);
+
+ // Debounced sync of text content to chatStore (for page refresh scenarios)
+ clearTimeout(syncTimerRef.current);
+ const convId = activeConvIdRef.current;
+ syncTimerRef.current = setTimeout(() => {
+ const textMessages = richMessages
+ .map((msg) => {
+ const contentStr =
+ typeof msg.content === 'string'
+ ? msg.content
+ : msg.content
+ .filter((c) => c.type === 'text')
+ .map((c) => (c as { type: 'text'; content: string }).content)
+ .join('\n');
+ return { role: msg.role as 'user' | 'assistant', content: contentStr };
+ })
+ .filter((msg) => msg.content);
+ if (textMessages.length > 0) {
+ setMessages(convId, textMessages);
+ }
+ }, 500);
+ }
+ return () => clearTimeout(syncTimerRef.current);
+ }, [richMessages, setMessages]);
+
+ // Handle conversation switching: save outgoing, load incoming
+ useEffect(() => {
+ const prevId = activeConvIdRef.current;
+
+ // Save outgoing conversation's rich messages before switching
+ if (prevId && prevId !== activeConversationId && richMessagesRef.current.length > 0) {
+ richMessagesByConversation.set(prevId, [...richMessagesRef.current]);
+ // Immediate sync to store for the outgoing conversation
+ const textMessages = richMessagesRef.current
+ .map((msg) => {
+ const contentStr =
+ typeof msg.content === 'string'
+ ? msg.content
+ : msg.content
+ .filter((c) => c.type === 'text')
+ .map((c) => (c as { type: 'text'; content: string }).content)
+ .join('\n');
+ return { role: msg.role as 'user' | 'assistant', content: contentStr };
+ })
+ .filter((msg) => msg.content);
+ if (textMessages.length > 0) {
+ setMessages(prevId, textMessages);
+ }
+ }
+
+ activeConvIdRef.current = activeConversationId;
+
+ // Load incoming conversation
+ if (!activeConversationId) {
+ setRichMessages([]);
+ return;
+ }
+
+ // If we just created this conversation from addRichMessage (transitioning from no conversation),
+ // richMessages already contain the correct data — save to cache and don't overwrite
+ if (!prevId && richMessagesRef.current.length > 0) {
+ richMessagesByConversation.set(activeConversationId, [...richMessagesRef.current]);
+ return;
+ }
+
+ // Check module cache first (has full rich content with components)
+ const cached = richMessagesByConversation.get(activeConversationId);
+ if (cached && cached.length > 0) {
+ setRichMessages(cached);
+ return;
+ }
+
+ // Fall back to chatStore (plain text only, for page refresh scenarios)
+ const conversation = getActiveConversation();
+ if (conversation && conversation.messages.length > 0) {
+ const loaded: RichChatMessage[] = conversation.messages.map((msg) => ({
+ id: msg.id,
+ role: msg.role,
+ content: msg.content,
+ timestamp: msg.timestamp,
+ }));
+ setRichMessages(loaded);
+ } else {
+ setRichMessages([]);
+ }
+ }, [activeConversationId, getActiveConversation, setMessages]);
+
+ const scrollToBottom = () => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ };
+
+ useEffect(() => {
+ scrollToBottom();
+ }, [richMessages]);
+
+ // Auto-resize textarea
+ useEffect(() => {
+ if (textareaRef.current) {
+ textareaRef.current.style.height = 'auto';
+ textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`;
+ }
+ }, [inputValue]);
+
+ const addRichMessage = useCallback(
+ (message: Omit) => {
+ const newMessage: RichChatMessage = {
+ ...message,
+ id: `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+ timestamp: new Date(),
+ };
+ setRichMessages((prev) => [...prev, newMessage]);
+
+ // Also add to store for persistence (simplified content)
+ // Read directly from the store to get the latest value synchronously,
+ // avoiding stale closure when multiple addRichMessage calls happen in the same render cycle.
+ let conversationId = useChatStore.getState().activeConversationId;
+ if (!conversationId) {
+ conversationId = createConversation();
+ // Update URL without React Router navigation to avoid component remount
+ // (React Router treats "/" and "/c/:id" as different routes, causing unmount/remount)
+ window.history.replaceState(null, '', `/c/${conversationId}`);
+ }
+ const contentStr =
+ typeof message.content === 'string'
+ ? message.content
+ : message.content
+ .filter((c) => c.type === 'text')
+ .map((c) => (c as { type: 'text'; content: string }).content)
+ .join('\n');
+
+ if (contentStr) {
+ addMessage(conversationId, {
+ role: message.role,
+ content: contentStr,
+ });
+ }
+
+ return newMessage.id;
+ },
+ [createConversation, addMessage],
+ );
+
+ const simulateStreamingResponse = useCallback(
+ async (content: MessageContentType[]) => {
+ // Add initial message with thinking state — thinking stays active until content appears
+ const thinkingItem: MessageContentType = {
+ type: 'thinking',
+ content: 'Analyzing your request and retrieving data...',
+ };
+
+ addRichMessage({
+ role: 'assistant',
+ content: [thinkingItem],
+ });
+
+ // Thinking phase — keep active for realistic duration
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ // Thinking collapses, content appears below (ChatGPT-style)
+ setRichMessages((prev) => {
+ const updated = [...prev];
+ const lastIdx = updated.length - 1;
+ if (updated[lastIdx]?.role === 'assistant') {
+ updated[lastIdx] = {
+ ...updated[lastIdx],
+ content: [thinkingItem, ...content],
+ };
+ }
+ return updated;
+ });
+ },
+ [addRichMessage],
+ );
+
+ const handleRunWorkflow = useCallback(() => {
+ addRichMessage({
+ role: 'user',
+ content: 'Run workflow',
+ });
+
+ simulateStreamingResponse([
+ {
+ type: 'text',
+ content:
+ 'I found 5 active workflows in your ShipSec Studio. Here are the available security workflows you can run:',
+ },
+ { type: 'workflow-buttons', workflows: mockWorkflows },
+ {
+ type: 'text',
+ content: 'Click on any workflow to start it, or tell me which one you want to configure.',
+ },
+ ]);
+ }, [addRichMessage, simulateStreamingResponse]);
+
+ const handleScanRepository = useCallback(() => {
+ addRichMessage({
+ role: 'user',
+ content: 'Scan repository',
+ });
+
+ simulateStreamingResponse([
+ {
+ type: 'text',
+ content:
+ "You have 200 repositories connected to ShipSec Studio. I've identified your most recently scanned repositories:",
+ },
+ {
+ type: 'repo-buttons',
+ repos: mockRepos,
+ intro:
+ 'Select a repository to scan, or I can recommend one based on the time since last scan:',
+ },
+ {
+ type: 'text',
+ content:
+ 'Pro tip: The ShipSecAI/studio repository was last scanned 2 hours ago. Would you like to run another scan with the same configuration?',
+ },
+ ]);
+ }, [addRichMessage, simulateStreamingResponse]);
+
+ const handleReviewFindings = useCallback(() => {
+ addRichMessage({
+ role: 'user',
+ content: 'Review findings',
+ });
+
+ const thinkingItem: MessageContentType = {
+ type: 'thinking',
+ content: 'Fetching security findings across all integrated sources...',
+ };
+
+ addRichMessage({
+ role: 'assistant',
+ content: [thinkingItem],
+ });
+
+ // Thinking stays active for the full duration, then collapses when content appears
+ setTimeout(() => {
+ setRichMessages((prev) => {
+ const updated = [...prev];
+ const lastIdx = updated.length - 1;
+ if (updated[lastIdx]?.role === 'assistant') {
+ updated[lastIdx] = {
+ ...updated[lastIdx],
+ content: [
+ thinkingItem,
+ {
+ type: 'text',
+ content:
+ "I've retrieved your latest security findings from across all integrated sources. Here's a summary of the last 5 security reports:",
+ },
+ { type: 'finding-cards', findings: mockFindings },
+ {
+ type: 'text',
+ content:
+ 'You have 3 critical findings from AWS Security Hub that require immediate attention. Would you like me to provide detailed remediation steps for those?',
+ },
+ ],
+ };
+ }
+ return updated;
+ });
+ }, 2500);
+ }, [addRichMessage]);
+
+ const handleWorkflowClick = useCallback(
+ (workflow: WorkflowOption) => {
+ addRichMessage({
+ role: 'user',
+ content: `Run ${workflow.name}`,
+ });
+
+ simulateStreamingResponse([
+ {
+ type: 'text',
+ content: `Starting **${workflow.name}** workflow...\n\n${workflow.description}\n\nThis workflow will scan your connected repositories and report any findings. I'll notify you when the scan is complete.`,
+ },
+ {
+ type: 'loading',
+ content: 'Initializing workflow...',
+ },
+ ]);
+
+ // Simulate completion — preserve thinking from simulateStreamingResponse
+ setTimeout(() => {
+ setRichMessages((prev) => {
+ const updated = [...prev];
+ const lastIdx = updated.length - 1;
+ if (updated[lastIdx]?.role === 'assistant') {
+ const prevContent = updated[lastIdx].content as MessageContentType[];
+ const thinkingItems = prevContent.filter((c) => c.type === 'thinking');
+ updated[lastIdx] = {
+ ...updated[lastIdx],
+ content: [
+ ...thinkingItems,
+ {
+ type: 'text',
+ content: `**${workflow.name}** workflow has been queued successfully!\n\nEstimated completion: 5-10 minutes\nRepositories to scan: 4\nNotification: Enabled\n\nI'll update you when the results are ready. Would you like to run another workflow or review existing findings?`,
+ },
+ ],
+ };
+ }
+ return updated;
+ });
+ }, 4500);
+ },
+ [addRichMessage, simulateStreamingResponse],
+ );
+
+ const handleRepoClick = useCallback(
+ (repo: RepoOption) => {
+ addRichMessage({
+ role: 'user',
+ content: `Scan ${repo.org}/${repo.name}`,
+ });
+
+ simulateStreamingResponse([
+ {
+ type: 'text',
+ content: `Initiating security scan for **${repo.org}/${repo.name}**...\n\nLast scanned: ${repo.lastScanned}\nBranch: main\nScan type: Full security suite`,
+ },
+ {
+ type: 'loading',
+ content: 'Running SAST, SCA, and secret detection...',
+ },
+ ]);
+
+ // Preserve thinking from simulateStreamingResponse
+ setTimeout(() => {
+ setRichMessages((prev) => {
+ const updated = [...prev];
+ const lastIdx = updated.length - 1;
+ if (updated[lastIdx]?.role === 'assistant') {
+ const prevContent = updated[lastIdx].content as MessageContentType[];
+ const thinkingItems = prevContent.filter((c) => c.type === 'thinking');
+ updated[lastIdx] = {
+ ...updated[lastIdx],
+ content: [
+ ...thinkingItems,
+ {
+ type: 'text',
+ content: `Scan completed for **${repo.org}/${repo.name}**!\n\n**Results Summary:**\n- Critical: 0\n- High: 2\n- Medium: 8\n- Low: 15\n\nThe 2 high severity findings are related to outdated dependencies. Would you like me to show detailed remediation steps?`,
+ },
+ ],
+ };
+ }
+ return updated;
+ });
+ }, 5000);
+ },
+ [addRichMessage, simulateStreamingResponse],
+ );
+
+ const handleFindingClick = useCallback(
+ (finding: FindingOption) => {
+ addRichMessage({
+ role: 'user',
+ content: `Show ${finding.source} findings`,
+ });
+
+ const thinkingItem: MessageContentType = {
+ type: 'thinking',
+ content: `Retrieving ${finding.source} findings...`,
+ };
+
+ addRichMessage({
+ role: 'assistant',
+ content: [thinkingItem],
+ });
+
+ // Thinking stays active for the full duration, then collapses when content appears
+ setTimeout(() => {
+ setRichMessages((prev) => {
+ const updated = [...prev];
+ const lastIdx = updated.length - 1;
+ if (updated[lastIdx]?.role === 'assistant') {
+ const findingDetails: Record = {
+ 'AWS Security Hub': `**AWS Security Hub — CRITICAL Findings**\n\nTotal: ${finding.count} findings\nLast run: ${finding.lastRun}\nRegion: us-east-1\n\n**Finding Details:**\n\n1. **S3 Bucket Public Access Enabled** — \`CRITICAL\`\n - Resource: \`arn:aws:s3:::prod-user-uploads\`\n - Control: S3.2 — S3 buckets should prohibit public read access\n - Account: 491203847561 (production)\n - Remediation: Enable S3 Block Public Access at the bucket level and review bucket policy for \`Principal: "*"\` statements\n\n2. **IAM Root Account Access Key Active** — \`CRITICAL\`\n - Resource: \`arn:aws:iam::491203847561:root\`\n - Control: IAM.4 — IAM root user access key should not exist\n - Account: 491203847561 (production)\n - Remediation: Delete root access keys immediately, create an IAM admin user with MFA, and rotate any services using root credentials\n\n3. **RDS Instance Publicly Accessible** — \`CRITICAL\`\n - Resource: \`arn:aws:rds:us-east-1:491203847561:db/prod-postgres\`\n - Control: RDS.2 — RDS DB instances should prohibit public access\n - Account: 491203847561 (production)\n - Remediation: Modify the RDS instance to disable public accessibility, ensure it resides in a private subnet, and use VPC security groups to restrict access\n\nWould you like me to generate remediation steps or create Jira tickets for these findings?`,
+
+ 'GitHub Advanced Security': `**GitHub Advanced Security — HIGH Findings**\n\nTotal: ${finding.count} findings across 4 repositories\nLast run: ${finding.lastRun}\n\n**Finding Details:**\n\n1. **SQL Injection via unsanitized query parameter** — \`HIGH\`\n - Rule: \`javascript/sql-injection\`\n - File: \`api-gateway/src/routes/users.ts:87\`\n - Branch: main\n - Snippet: \`db.query(\`SELECT * FROM users WHERE id = \${req.params.id}\`)\`\n - Remediation: Use parameterized queries with \`$1\` placeholders instead of string interpolation\n\n2. **Leaked GitHub Personal Access Token** — \`HIGH\`\n - Rule: \`secret-scanning/github-token\`\n - File: \`studio/.env.example:12\`\n - Branch: main\n - Remediation: Revoke the token in GitHub Settings > Developer settings > Tokens, rotate and store in a secrets manager\n\n3. **Prototype Pollution in lodash <4.17.21** — \`HIGH\`\n - Rule: \`dependabot/npm/lodash\`\n - File: \`frontend-app/package-lock.json\`\n - Remediation: Run \`npm audit fix\` or update lodash to >=4.17.21\n\n_Showing 3 of ${finding.count} findings. Would you like me to show all findings or generate remediation PRs?_`,
+
+ 'Semgrep SAST': `**Semgrep SAST — MEDIUM Findings**\n\nTotal: ${finding.count} findings across 6 repositories\nLast run: ${finding.lastRun}\nRuleset: p/owasp-top-ten, p/typescript\n\n**Top Findings by Category:**\n\n| Category | Count | Severity |\n|---|---|---|\n| Missing input validation | 9 | Medium |\n| Insecure crypto usage | 6 | Medium |\n| Hardcoded configuration | 5 | Medium |\n| Missing error handling | 4 | Medium |\n| Insecure deserialization | 4 | Medium |\n\n**Sample Findings:**\n\n1. **Use of \`Math.random()\` for token generation** — \`MEDIUM\`\n - Rule: \`javascript.lang.security.insecure-randomness\`\n - File: \`auth-service/src/utils/token.ts:23\`\n - Remediation: Replace with \`crypto.randomBytes()\` or \`crypto.randomUUID()\`\n\n2. **Unvalidated redirect URL** — \`MEDIUM\`\n - Rule: \`javascript.express.security.open-redirect\`\n - File: \`api-gateway/src/middleware/auth.ts:56\`\n - Remediation: Validate redirect URLs against an allowlist of trusted domains\n\nWould you like a full breakdown by repository, or should I prioritize remediation for a specific category?`,
+
+ 'Trivy Container Scan': `**Trivy Container Scan — HIGH Findings**\n\nTotal: ${finding.count} findings across 3 images\nLast run: ${finding.lastRun}\n\n**Image: \`ghcr.io/shipsecai/api-gateway:latest\`** (4 findings)\n\n1. **CVE-2024-38816 — Spring Framework path traversal** — \`HIGH\` (CVSS 8.1)\n - Package: \`org.springframework:spring-webmvc 6.1.6\`\n - Fixed in: 6.1.13\n - Remediation: Update Spring Boot parent to >=3.3.4\n\n2. **CVE-2024-47554 — Apache Commons IO ReDoS** — \`HIGH\` (CVSS 7.5)\n - Package: \`commons-io:commons-io 2.11.0\`\n - Fixed in: 2.14.0\n\n**Image: \`ghcr.io/shipsecai/frontend-app:latest\`** (2 findings)\n\n3. **CVE-2024-21538 — cross-spawn ReDoS** — \`HIGH\` (CVSS 7.5)\n - Package: \`cross-spawn 7.0.3\`\n - Fixed in: 7.0.5\n\n**Image: \`ghcr.io/shipsecai/auth-service:latest\`** (1 finding)\n\n4. **GHSA-72xf-g2v4-qvf3 — undici request smuggling** — \`HIGH\` (CVSS 7.5)\n - Package: \`undici 5.28.3\`\n - Fixed in: 5.28.4\n\nWould you like me to update the Dockerfiles with patched base images?`,
+
+ 'Checkov IaC Analysis': `**Checkov IaC Analysis — LOW Findings**\n\nTotal: ${finding.count} findings across 12 Terraform files\nLast run: ${finding.lastRun}\nFramework: Terraform v1.7.x\n\n**Findings by Category:**\n\n| Category | Count | Severity |\n|---|---|---|\n| Missing resource tagging | 18 | Low |\n| Logging not enabled | 12 | Low |\n| Encryption at rest defaults | 8 | Low |\n| Backup not configured | 7 | Low |\n\n**Sample Findings:**\n\n1. **S3 bucket missing versioning** — \`LOW\`\n - Check: CKV_AWS_21\n - File: \`infra/modules/storage/main.tf:34\`\n - Remediation: Add \`versioning { enabled = true }\` block\n\n2. **CloudWatch log group missing retention policy** — \`LOW\`\n - Check: CKV_AWS_158\n - File: \`infra/modules/monitoring/main.tf:12\`\n - Remediation: Set \`retention_in_days = 90\`\n\n3. **EC2 instance missing detailed monitoring** — \`LOW\`\n - Check: CKV_AWS_126\n - File: \`infra/environments/staging/main.tf:67\`\n - Remediation: Add \`monitoring = true\`\n\nThese are low-severity hygiene items. Would you like me to generate a Terraform PR to fix all tagging issues at once?`,
+ };
+
+ updated[lastIdx] = {
+ ...updated[lastIdx],
+ content: [
+ thinkingItem,
+ {
+ type: 'text',
+ content:
+ findingDetails[finding.source] ||
+ `**${finding.source} — ${finding.severity.toUpperCase()} Findings**\n\nTotal: ${finding.count} findings\nLast run: ${finding.lastRun}\n\nNo detailed breakdown available for this source. Would you like me to fetch the raw findings data?`,
+ },
+ ],
+ };
+ }
+ return updated;
+ });
+ }, 2000);
+ },
+ [addRichMessage],
+ );
+
+ const handleQuickActionClick = useCallback(
+ (action: QuickActionOption) => {
+ addRichMessage({
+ role: 'user',
+ content: action.name,
+ });
+
+ simulateStreamingResponse([
+ {
+ type: 'text',
+ content: `Executing **${action.name}**...\n\n${action.description}`,
+ },
+ {
+ type: 'loading',
+ content: 'Processing...',
+ },
+ ]);
+
+ // Preserve thinking from simulateStreamingResponse
+ setTimeout(() => {
+ setRichMessages((prev) => {
+ const updated = [...prev];
+ const lastIdx = updated.length - 1;
+ if (updated[lastIdx]?.role === 'assistant') {
+ const prevContent = updated[lastIdx].content as MessageContentType[];
+ const thinkingItems = prevContent.filter((c) => c.type === 'thinking');
+ updated[lastIdx] = {
+ ...updated[lastIdx],
+ content: [
+ ...thinkingItems,
+ {
+ type: 'text',
+ content: `**${action.name}** completed!\n\n${
+ action.id === 'qa1'
+ ? 'Your security report has been generated and is available in the Reports section. Key highlights:\n- Overall security score: 78/100\n- 3 critical issues resolved this week\n- 12 new findings require attention'
+ : action.id === 'qa2'
+ ? 'Compliance check completed:\n\n- SOC2: 94% compliant (2 controls pending)\n- HIPAA: 89% compliant (review data retention)\n- PCI-DSS: 97% compliant'
+ : action.id === 'qa3'
+ ? 'Alert settings updated. You will receive notifications for:\n- Critical findings: Immediately\n- High findings: Within 1 hour\n- Medium/Low: Daily digest'
+ : 'Full security suite initiated. Running:\n- SAST analysis\n- Dependency audit\n- Secret scanning\n- Container analysis\n\nEstimated completion: 15-20 minutes'
+ }`,
+ },
+ ],
+ };
+ }
+ return updated;
+ });
+ }, 4000);
+ },
+ [addRichMessage, simulateStreamingResponse],
+ );
+
+ // Handler: "Investigate alert" welcome screen button
+ const handleInvestigateAlert = useCallback(() => {
+ addRichMessage({
+ role: 'user',
+ content: 'Investigate alert',
+ });
+
+ const thinkingItem: MessageContentType = {
+ type: 'thinking',
+ content: 'Connecting to AWS GuardDuty and fetching recent alerts...',
+ };
+
+ addRichMessage({
+ role: 'assistant',
+ content: [thinkingItem],
+ });
+
+ // Thinking stays active for the full duration, then collapses when content appears
+ setTimeout(() => {
+ setRichMessages((prev) => {
+ const updated = [...prev];
+ const lastIdx = updated.length - 1;
+ if (updated[lastIdx]?.role === 'assistant') {
+ updated[lastIdx] = {
+ ...updated[lastIdx],
+ content: [
+ thinkingItem,
+ {
+ type: 'text',
+ content:
+ 'I found **3 recent security alerts** in AWS GuardDuty across your monitored accounts:',
+ },
+ { type: 'guardduty-alerts', alerts: mockGuardDutyAlerts },
+ {
+ type: 'text',
+ content:
+ 'Would you like me to investigate any of these alerts? Just describe which one interests you.',
+ },
+ ],
+ };
+ }
+ return updated;
+ });
+ }, 2000);
+ }, [addRichMessage]);
+
+ // Multi-step investigation flow
+ const runInvestigationFlow = useCallback(() => {
+ addRichMessage({
+ role: 'assistant',
+ content: [
+ { type: 'thinking', content: 'Analyzing your request and pulling alert details...' },
+ ],
+ });
+
+ const updateLastAssistant = (content: MessageContentType[]) => {
+ setRichMessages((prev) => {
+ const updated = [...prev];
+ const lastIdx = updated.length - 1;
+ if (updated[lastIdx]?.role === 'assistant') {
+ updated[lastIdx] = { ...updated[lastIdx], content };
+ }
+ return updated;
+ });
+ };
+
+ // Investigation content blocks (accumulated step by step)
+ const alertHeader: MessageContentType = {
+ type: 'text',
+ content:
+ '🚨 **Incident Investigation Summary - ShipSec**\n\n🔗 **GuardDuty Alert: Unusual SSH Login Detected**\n\n**Summary:** Suspicious SSH login detected from an unauthorized IP **189.45.23.18** on EC2 instance **i-07abc123d456ef789**.\n\n---\n\n🕵️ **Investigation Timeline** *(Auto-correlated across AWS, GSuite, Rippling)*',
+ };
+
+ const guardDutyStep: MessageContentType = {
+ type: 'text',
+ content: `**1. AWS GuardDuty Alert Triggered**\n\n **Time:** **${ALERT_DATE_PRIMARY}**\n **Instance:** **i-07abc123d456ef789**\n **Source IP:** **189.45.23.18** *(GeoIP: São Paulo, Brazil)*\n **SSH User:** **ec2-user**`,
+ };
+
+ const cloudTrailStep: MessageContentType = {
+ type: 'text',
+ content:
+ '**2. AWS CloudTrail Log Review**\n\n ✅ Verified SSH access from the IP at the reported time\n 🔘 Session initiated by IAM role **DevOpsAccessRole**',
+ };
+
+ const ripplingStep: MessageContentType = {
+ type: 'text',
+ content: `**3. Rippling (HRMS) Context**\n\n 🎯 IAM role **DevOpsAccessRole** maps to user: **Pranjal Paliwal**\n 🏖️ Pranjal is marked **Out of Office (PTO)** from **${PTO_START}** to **${PTO_END}**\n ❗ Login occurred during PTO`,
+ };
+
+ const gsuiteStep: MessageContentType = {
+ type: 'text',
+ content:
+ '**4. GSuite Activity Check**\n\n 📁 No recent file access from Pranjal in last 72 hrs\n ✉️ No login to Gmail or Drive from Brazil IP',
+ };
+
+ const conclusion: MessageContentType = {
+ type: 'text',
+ content:
+ '---\n\n💡 **Conclusion:**\n\nThe SSH login from **189.45.23.18** appears **unauthorized**. IAM role belongs to a user on PTO with no matching activity in GSuite.',
+ };
+
+ const recommendedAction: MessageContentType = {
+ type: 'text',
+ content:
+ '🚨 **Recommended Action:**\n\n 🔐 Temporarily revoke **DevOpsAccessRole** credentials\n 👤 Notify user: *"Hi Pranjal, was this you?"*\n 📋 Initiate IR protocol with SecOps team',
+ };
+
+ const actionButtons: MessageContentType = {
+ type: 'action-buttons',
+ buttons: [
+ { id: 'confirm-pranjal', label: 'Confirm with Pranjal', emoji: '✅', variant: 'primary' },
+ {
+ id: 'revoke-role',
+ label: 'Temporarily revoke DevOpsAcc...',
+ emoji: '🚫',
+ variant: 'destructive',
+ },
+ ],
+ };
+
+ // Step 0: Show alert header + thinking for GuardDuty (1.2s)
+ setTimeout(() => {
+ updateLastAssistant([
+ alertHeader,
+ { type: 'thinking', content: 'Fetching full alert details from AWS GuardDuty via MCP...' },
+ ]);
+ }, 1200);
+
+ // Step 1: GuardDuty details + thinking for CloudTrail (2.7s)
+ setTimeout(() => {
+ updateLastAssistant([
+ alertHeader,
+ guardDutyStep,
+ { type: 'thinking', content: 'Cross-referencing with AWS CloudTrail logs via MCP...' },
+ ]);
+ }, 2700);
+
+ // Step 2: CloudTrail + thinking for Rippling (4.2s)
+ setTimeout(() => {
+ updateLastAssistant([
+ alertHeader,
+ guardDutyStep,
+ cloudTrailStep,
+ {
+ type: 'thinking',
+ content: 'Querying Rippling HRMS via MCP to identify user behind IAM role...',
+ },
+ ]);
+ }, 4200);
+
+ // Step 3: Rippling + thinking for GSuite (5.7s)
+ setTimeout(() => {
+ updateLastAssistant([
+ alertHeader,
+ guardDutyStep,
+ cloudTrailStep,
+ ripplingStep,
+ {
+ type: 'thinking',
+ content: 'Checking GSuite activity via MCP for corroborating evidence...',
+ },
+ ]);
+ }, 5700);
+
+ // Step 4: GSuite + thinking for conclusion (7.2s)
+ setTimeout(() => {
+ updateLastAssistant([
+ alertHeader,
+ guardDutyStep,
+ cloudTrailStep,
+ ripplingStep,
+ gsuiteStep,
+ { type: 'thinking', content: 'Correlating findings and generating conclusion...' },
+ ]);
+ }, 7200);
+
+ // Step 5: Full result with conclusion, recommended action, and buttons (8.5s)
+ setTimeout(() => {
+ updateLastAssistant([
+ {
+ type: 'thinking',
+ content: 'Investigated alert across GuardDuty, CloudTrail, Rippling, and GSuite',
+ },
+ alertHeader,
+ guardDutyStep,
+ cloudTrailStep,
+ ripplingStep,
+ gsuiteStep,
+ conclusion,
+ recommendedAction,
+ actionButtons,
+ ]);
+ }, 8500);
+ }, [addRichMessage]);
+
+ // Handler: GuardDuty alert card click
+ const handleAlertClick = useCallback(
+ (_alert: GuardDutyAlert) => {
+ runInvestigationFlow();
+ },
+ [runInvestigationFlow],
+ );
+
+ // Handler: action button clicks (Confirm with Pranjal / Revoke role)
+ const handleActionButtonClick = useCallback(
+ (button: ActionButton) => {
+ if (button.id === 'confirm-pranjal') {
+ addRichMessage({
+ role: 'user',
+ content: 'Confirm with Pranjal',
+ });
+
+ const thinkingItem: MessageContentType = {
+ type: 'thinking',
+ content: 'Sending Slack notification to Pranjal Paliwal via MCP...',
+ };
+
+ addRichMessage({
+ role: 'assistant',
+ content: [thinkingItem],
+ });
+
+ // Thinking stays active, then collapses when content appears
+ setTimeout(() => {
+ setRichMessages((prev) => {
+ const updated = [...prev];
+ const lastIdx = updated.length - 1;
+ if (updated[lastIdx]?.role === 'assistant') {
+ updated[lastIdx] = {
+ ...updated[lastIdx],
+ content: [
+ thinkingItem,
+ {
+ type: 'text',
+ content: `✅ **Slack notification sent to Pranjal Paliwal**\n\nUsing **Slack MCP** to send a direct message.\n\n---\n\n**Direct message sent to:** Pranjal Paliwal\n\n**Message preview:**\n> 🔔 **Security Alert — Action Required**\n>\n> Hi Pranjal, we detected an SSH login to EC2 instance **i-07abc123d456ef789** from IP **189.45.23.18** (São Paulo, Brazil) at **${ALERT_DATE_PRIMARY}** using your IAM role **DevOpsAccessRole**.\n>\n> Our records show you are currently on PTO. **Was this you?**\n>\n> Please reply with ✅ if this was authorized or 🚫 if this was not you.\n\n---\n\nI'll monitor for Pranjal's response and update you. If no response within 30 minutes, I'll automatically escalate to the SecOps team.`,
+ },
+ ],
+ };
+ }
+ return updated;
+ });
+ }, 2000);
+ } else if (button.id === 'revoke-role') {
+ addRichMessage({
+ role: 'user',
+ content: 'Temporarily revoke DevOpsAccessRole',
+ });
+
+ const thinkingItem: MessageContentType = {
+ type: 'thinking',
+ content: 'Revoking DevOpsAccessRole credentials via AWS IAM MCP...',
+ };
+
+ addRichMessage({
+ role: 'assistant',
+ content: [thinkingItem],
+ });
+
+ // Thinking stays active, then collapses when content appears
+ setTimeout(() => {
+ setRichMessages((prev) => {
+ const updated = [...prev];
+ const lastIdx = updated.length - 1;
+ if (updated[lastIdx]?.role === 'assistant') {
+ updated[lastIdx] = {
+ ...updated[lastIdx],
+ content: [
+ thinkingItem,
+ {
+ type: 'text',
+ content: `🚫 **DevOpsAccessRole temporarily revoked**\n\nUsing **AWS IAM MCP** to attach a deny-all policy and invalidate active sessions.\n\n---\n\n| Action | Status |\n|---|---|\n| **Inline policy attached** | ✅ DenyAll policy added to DevOpsAccessRole |\n| **Active sessions invalidated** | ✅ All sessions older than now revoked |\n| **CloudTrail logging** | ✅ Enhanced logging enabled for this role |\n| **Rollback window** | 24 hours (auto-restore if confirmed safe) |\n\n**Role ARN:** arn:aws:iam::491203847561:role/DevOpsAccessRole\n**Policy:** ShipSec-EmergencyDenyAll-${TODAY_COMPACT}\n\n---\n\nThe role is now locked down. Any services using this role will lose access. I've also notified the SecOps team in **#incident-response** about this containment action.\n\nWould you like me to initiate the full IR protocol or wait for Pranjal's confirmation first?`,
+ },
+ ],
+ };
+ }
+ return updated;
+ });
+ }, 2500);
+ }
+ },
+ [addRichMessage],
+ );
+
+ const handleSend = () => {
+ if (!inputValue.trim()) return;
+
+ const userMessage = inputValue.trim();
+ const lowerMessage = userMessage.toLowerCase();
+
+ addRichMessage({
+ role: 'user',
+ content: userMessage,
+ });
+
+ // Handle different inputs
+ if (lowerMessage.includes('run workflow') || lowerMessage.includes('workflow')) {
+ setInputValue('');
+ simulateStreamingResponse([
+ {
+ type: 'text',
+ content:
+ 'I found 5 active workflows in your ShipSec Studio. Here are the available security workflows you can run:',
+ },
+ { type: 'workflow-buttons', workflows: mockWorkflows },
+ {
+ type: 'text',
+ content: 'Click on any workflow to start it, or tell me which one you want to configure.',
+ },
+ ]);
+ return;
+ }
+
+ if (lowerMessage.includes('scan') || lowerMessage.includes('repository')) {
+ setInputValue('');
+ simulateStreamingResponse([
+ {
+ type: 'text',
+ content:
+ 'You have 200 repositories connected to ShipSec Studio. Here are your most recently scanned ones:',
+ },
+ {
+ type: 'repo-buttons',
+ repos: mockRepos,
+ intro: 'Select a repository to scan:',
+ },
+ ]);
+ return;
+ }
+
+ if (
+ lowerMessage.includes('investigate') ||
+ lowerMessage.includes('alert') ||
+ lowerMessage.includes('ssh') ||
+ lowerMessage.includes('login')
+ ) {
+ setInputValue('');
+ runInvestigationFlow();
+ return;
+ }
+
+ if (
+ lowerMessage.includes('finding') ||
+ lowerMessage.includes('review') ||
+ lowerMessage.includes('aws')
+ ) {
+ setInputValue('');
+ simulateStreamingResponse([
+ {
+ type: 'text',
+ content:
+ "I've retrieved your latest security findings from across all integrated sources. Here's a summary of the last 5 security reports:",
+ },
+ { type: 'finding-cards', findings: mockFindings },
+ {
+ type: 'text',
+ content:
+ 'You have 3 critical findings from AWS Security Hub that require immediate attention. Would you like me to provide detailed remediation steps for those?',
+ },
+ ]);
+ return;
+ }
+
+ if (
+ lowerMessage.includes('jira') ||
+ lowerMessage.includes('ticket') ||
+ lowerMessage.includes('create ticket')
+ ) {
+ setInputValue('');
+
+ const thinkingItem: MessageContentType = {
+ type: 'thinking',
+ content: 'Connecting to JIRA MCP via ShipSec secure proxy and creating ticket...',
+ };
+
+ addRichMessage({
+ role: 'assistant',
+ content: [thinkingItem],
+ });
+
+ // Thinking stays active, then collapses when final content appears
+ setTimeout(() => {
+ setRichMessages((prev) => {
+ const updated = [...prev];
+ const lastIdx = updated.length - 1;
+ if (updated[lastIdx]?.role === 'assistant') {
+ updated[lastIdx] = {
+ ...updated[lastIdx],
+ content: [
+ thinkingItem,
+ {
+ type: 'text',
+ content: `I've connected to the **JIRA MCP server** via the ShipSec secure HTTP proxy and created a ticket for all 3 critical AWS Security Hub findings. The ticket has been assigned to **Pranjal** as he's the DevOps Lead.\n\n---\n\n**[SD-47](https://shipsec.atlassian.net/browse/SD-47)** — \`Critical AWS Security Hub Findings — Immediate Remediation Required\`\n\n| Field | Value |\n|---|---|\n| **Project** | ShipSec DevOps (SD) |\n| **Type** | Bug |\n| **Priority** | 🔴 Critical |\n| **Assignee** | Pranjal (DevOps Lead) |\n| **Labels** | \`aws\`, \`security-hub\`, \`critical\`, \`production\` |\n| **Due Date** | Feb 7, 2025 |\n\n**Description includes:**\n1. **S3 Bucket Public Access Enabled** — \`prod-user-uploads\` bucket has public read access\n2. **IAM Root Account Access Key Active** — Root access keys exist on production account \n3. **RDS Instance Publicly Accessible** — \`prod-postgres\` is publicly accessible\n\n**Acceptance Criteria:**\n- [ ] S3 Block Public Access enabled on \`prod-user-uploads\`\n- [ ] Root access keys deleted and IAM admin user created with MFA\n- [ ] RDS instance moved to private subnet with public access disabled\n\n🔗 **Ticket URL:** [https://shipsec.atlassian.net/browse/SD-47](https://shipsec.atlassian.net/browse/SD-47)\n\n---\n\nPranjal has been notified via Slack (\`#devops-alerts\`) and email. Would you like me to add any watchers, link this to an existing epic, or escalate the priority?`,
+ },
+ ],
+ };
+ }
+ return updated;
+ });
+ }, 3000);
+ return;
+ }
+
+ // Default response
+ setInputValue('');
+ simulateStreamingResponse([
+ {
+ type: 'text',
+ content: `I understand you want help with "${userMessage}". As your security assistant, I can help you with:\n\n• Running security workflows\n• Scanning repositories for vulnerabilities\n• Reviewing security findings\n• Performing quick security actions\n\nWhat would you like to do?`,
+ },
+ ]);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ };
+
+ const handleSuggestedAction = (action: string) => {
+ const lowerAction = action.toLowerCase();
+
+ if (lowerAction === 'run workflow') {
+ handleRunWorkflow();
+ } else if (lowerAction === 'scan repository') {
+ handleScanRepository();
+ } else if (lowerAction === 'review findings') {
+ handleReviewFindings();
+ } else if (lowerAction === 'investigate alert') {
+ handleInvestigateAlert();
+ } else {
+ setInputValue(action);
+ textareaRef.current?.focus();
+ }
+ };
+
+ return (
+
+ {/* Header with sidebar toggle and status bar */}
+
+ {/* Sidebar toggle button - positioned at the start */}
+ {!isMobile && (
+
+ )}
+ {/* Status indicators */}
+
+
+
+ {/* Messages area */}
+
+ {richMessages.length === 0 ? (
+
+ ) : (
+
+ {richMessages.map((message) => (
+
+ ))}
+
+
+ )}
+
+
+ {/* Input area */}
+
+
+ );
+}
diff --git a/frontend/src/pages/WorkflowList.tsx b/frontend/src/pages/WorkflowList.tsx
index 90ef12de..ded04338 100644
--- a/frontend/src/pages/WorkflowList.tsx
+++ b/frontend/src/pages/WorkflowList.tsx
@@ -21,7 +21,16 @@ import {
} from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Skeleton } from '@/components/ui/skeleton';
-import { Workflow, AlertCircle, Trash2, Loader2, Info } from 'lucide-react';
+import {
+ Workflow,
+ AlertCircle,
+ Trash2,
+ Loader2,
+ Info,
+ Plus,
+ PanelLeftOpen,
+ PanelLeftClose,
+} from 'lucide-react';
import { api } from '@/services/api';
import { getStatusBadgeClassFromStatus } from '@/utils/statusBadgeStyles';
import { WorkflowMetadataSchema, type WorkflowMetadataNormalized } from '@/schemas/workflow';
@@ -30,6 +39,8 @@ import { hasAdminRole } from '@/utils/auth';
import { track, Events } from '@/features/analytics/events';
import { useAuth } from '@/auth/auth-context';
import { useRunStore } from '@/store/runStore';
+import { useSidebar } from '@/components/layout/sidebar-context';
+import { cn } from '@/lib/utils';
export function WorkflowList() {
const navigate = useNavigate();
@@ -50,6 +61,9 @@ export function WorkflowList() {
const token = useAuthStore((state) => state.token);
const adminUsername = useAuthStore((state) => state.adminUsername);
+ // Sidebar toggle from AppLayout context
+ const { isOpen: sidebarOpen, toggle: toggleSidebar, isMobile } = useSidebar();
+
const MAX_RETRY_ATTEMPTS = 30; // Try for ~60 seconds (30 attempts × 2s)
const RETRY_INTERVAL_MS = 2000; // 2 seconds between retries
@@ -186,23 +200,63 @@ export function WorkflowList() {
};
return (
-
-
- {isReadOnly && (
-
- You are viewing workflows with read-only access. Administrators can create and edit
- workflows.
-
+
+ {/* Header with sidebar toggle */}
+
+ {/* Sidebar toggle button */}
+ {!isMobile && (
+
)}
-
-
Your Workflows
-
- Create and manage security automation workflows with powerful visual tools
-
+ {/* Page title */}
+
+
Your Workflows
- {/*
+ {/* New Workflow button */}
+ {canManageWorkflows && (
+
+ )}
+
+
+
+
+ {isReadOnly && (
+
+ You are viewing workflows with read-only access. Administrators can create and edit
+ workflows.
+
+ )}
+
+
+
+ Create and manage security automation workflows with powerful visual tools
+
+
+
+ {/*
*/}
- {isLoading ? (
-
- {retryCount > 0 && (
-
-
-
-
- Waiting for backend... ({retryCount}/{MAX_RETRY_ATTEMPTS})
-
+ {isLoading ? (
+
+ {retryCount > 0 && (
+
+
+
+
+ Waiting for backend... ({retryCount}/{MAX_RETRY_ATTEMPTS})
+
+
+ )}
+
+
+
+
+ Name
+ Nodes
+ Status
+
+
+
+
+
+ Last Run
+
+
+
+
+ Times shown in your local timezone
+
+
+
+
+
+
+
+
+
+ Last Updated
+
+
+
+
+ Times shown in your local timezone
+
+
+
+
+ {canManageWorkflows && Actions}
+
+
+
+ {Array.from({ length: 5 }).map((_, idx) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {canManageWorkflows && (
+
+
+
+ )}
+
+ ))}
+
+
- )}
-
-
-
-
- Name
- Nodes
- Status
-
-
-
-
-
- Last Run
-
-
-
-
- Times shown in your local timezone
-
-
-
-
-
-
-
-
-
- Last Updated
-
-
-
-
- Times shown in your local timezone
-
-
-
-
- {canManageWorkflows && Actions}
-
-
-
- {Array.from({ length: 5 }).map((_, idx) => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ ) : error ? (
+
+
+
Failed to load workflows
+
{error}
+
+
+ ) : workflows.length === 0 ? (
+
+
+
No workflows yet
+
+ Create your first workflow to get started
+
+
+
+ ) : (
+
+
+
+
+
+ Name
+ Nodes
+ Status
+
+
+
+
+
+ Last Run
+
+
+
+
+ Times shown in your local timezone
+
+
+
+
+
+
+
+
+
+ Last Updated
+
+
+
+
+ Times shown in your local timezone
+
+
+
+
{canManageWorkflows && (
-
-
-
+ Actions
)}
- ))}
-
-
-
-
- ) : error ? (
-
-
-
Failed to load workflows
-
{error}
-
-
- ) : workflows.length === 0 ? (
-
-
-
No workflows yet
-
- Create your first workflow to get started
-
-
-
- ) : (
-
-
-
-
-
- Name
- Nodes
- Status
-
-
-
-
-
- Last Run
-
-
-
-
- Times shown in your local timezone
-
-
-
-
-
-
-
-
-
- Last Updated
-
-
-
-
- Times shown in your local timezone
-
-
-
-
- {canManageWorkflows && (
- Actions
- )}
-
-
-
- {workflows.map((workflow) => (
- navigate(`/workflows/${workflow.id}`)}
- onDeleteClick={handleDeleteClick}
- />
- ))}
-
-
+
+
+ {workflows.map((workflow) => (
+ navigate(`/workflows/${workflow.id}`)}
+ onDeleteClick={handleDeleteClick}
+ />
+ ))}
+
+
+
-
- )}
+ )}
+
{canManageWorkflows && (
diff --git a/frontend/src/store/chatStore.ts b/frontend/src/store/chatStore.ts
new file mode 100644
index 00000000..3976c838
--- /dev/null
+++ b/frontend/src/store/chatStore.ts
@@ -0,0 +1,165 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+export interface ChatMessage {
+ id: string;
+ role: 'user' | 'assistant';
+ content: string;
+ timestamp: Date;
+}
+
+export interface Conversation {
+ id: string;
+ title: string;
+ messages: ChatMessage[];
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+interface ChatStore {
+ conversations: Conversation[];
+ activeConversationId: string | null;
+ isLoading: boolean;
+
+ // Actions
+ createConversation: () => string;
+ setActiveConversation: (id: string | null) => void;
+ addMessage: (conversationId: string, message: Omit
) => void;
+ setMessages: (
+ conversationId: string,
+ messages: { role: 'user' | 'assistant'; content: string }[],
+ ) => void;
+ getActiveConversation: () => Conversation | null;
+ deleteConversation: (id: string) => void;
+ renameConversation: (id: string, newTitle: string) => void;
+ clearConversations: () => void;
+}
+
+const generateId = () => Math.random().toString(36).substring(2, 15);
+
+// Rehydrate Date objects from JSON strings
+function rehydrateDates(conversations: Conversation[]): Conversation[] {
+ return conversations.map((conv) => ({
+ ...conv,
+ createdAt: new Date(conv.createdAt),
+ updatedAt: new Date(conv.updatedAt),
+ messages: conv.messages.map((msg) => ({
+ ...msg,
+ timestamp: new Date(msg.timestamp),
+ })),
+ }));
+}
+
+export const useChatStore = create()(
+ persist(
+ (set, get) => ({
+ conversations: [],
+ activeConversationId: null,
+ isLoading: false,
+
+ createConversation: () => {
+ const id = generateId();
+ const newConversation: Conversation = {
+ id,
+ title: 'New conversation',
+ messages: [],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ set((state) => ({
+ conversations: [newConversation, ...state.conversations],
+ activeConversationId: id,
+ }));
+ return id;
+ },
+
+ setActiveConversation: (id) => {
+ set({ activeConversationId: id });
+ },
+
+ addMessage: (conversationId, message) => {
+ const id = generateId();
+ const newMessage: ChatMessage = {
+ ...message,
+ id,
+ timestamp: new Date(),
+ };
+
+ set((state) => ({
+ conversations: state.conversations.map((conv) => {
+ if (conv.id === conversationId) {
+ // Update title based on first user message
+ const newTitle =
+ conv.messages.length === 0 && message.role === 'user'
+ ? message.content.slice(0, 30) + (message.content.length > 30 ? '...' : '')
+ : conv.title;
+ return {
+ ...conv,
+ title: newTitle,
+ messages: [...conv.messages, newMessage],
+ updatedAt: new Date(),
+ };
+ }
+ return conv;
+ }),
+ }));
+ },
+
+ setMessages: (conversationId, messages) => {
+ set((state) => ({
+ conversations: state.conversations.map((conv) => {
+ if (conv.id === conversationId) {
+ return {
+ ...conv,
+ messages: messages.map((msg) => ({
+ ...msg,
+ id: generateId(),
+ timestamp: new Date(),
+ })),
+ updatedAt: new Date(),
+ };
+ }
+ return conv;
+ }),
+ }));
+ },
+
+ getActiveConversation: () => {
+ const state = get();
+ return state.conversations.find((c) => c.id === state.activeConversationId) || null;
+ },
+
+ deleteConversation: (id) => {
+ set((state) => ({
+ conversations: state.conversations.filter((c) => c.id !== id),
+ activeConversationId:
+ state.activeConversationId === id ? null : state.activeConversationId,
+ }));
+ },
+
+ renameConversation: (id, newTitle) => {
+ set((state) => ({
+ conversations: state.conversations.map((conv) =>
+ conv.id === id ? { ...conv, title: newTitle, updatedAt: new Date() } : conv,
+ ),
+ }));
+ },
+
+ clearConversations: () => {
+ set({ conversations: [], activeConversationId: null });
+ },
+ }),
+ {
+ name: 'shipsec-chat-store',
+ partialize: (state) => ({
+ conversations: state.conversations,
+ activeConversationId: state.activeConversationId,
+ }),
+ onRehydrateStorage: () => (state) => {
+ if (state) {
+ state.conversations = rehydrateDates(state.conversations);
+ }
+ },
+ },
+ ),
+);