diff --git a/components/frontend/package-lock.json b/components/frontend/package-lock.json index 78539d3ad..8b9a5523e 100644 --- a/components/frontend/package-lock.json +++ b/components/frontend/package-lock.json @@ -2553,7 +2553,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.10.tgz", "integrity": "sha512-BKLss9Y8PQ9IUjPYQiv3/Zmlx92uxffUOX8ZZNoQlCIZBJPT5M+GOMQj7xislvVQ6l1BstBjcX0XB/aHfFYVNw==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.90.10" }, @@ -2693,7 +2692,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz", "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2704,7 +2702,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2761,7 +2758,6 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -3285,7 +3281,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4298,7 +4293,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4472,7 +4466,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8167,7 +8160,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8177,7 +8169,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -8190,7 +8181,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz", "integrity": "sha512-2KnjpgG2Rhbi+CIiIBQQ9Df6sMGH5ExNyFl4Hw9qO7pIqMBR8Bvu9RQyjl3JM4vehzCh9soiNUM/xYMswb2EiA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -9132,7 +9122,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9330,7 +9319,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/components/frontend/src/app/globals.css b/components/frontend/src/app/globals.css index 0af3f0175..b8366cfcc 100644 --- a/components/frontend/src/app/globals.css +++ b/components/frontend/src/app/globals.css @@ -194,6 +194,27 @@ } } +/* Animation keyframes for welcome experience */ +@keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fade-in-char { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + /* Thin scrollbar styling - Cross-browser support */ .scrollbar-thin { /* Firefox */ diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/workflows-accordion.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/workflows-accordion.tsx index 25c9ac85e..2200578dc 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/workflows-accordion.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/workflows-accordion.tsx @@ -1,23 +1,23 @@ "use client"; -import { Play, Loader2, Workflow, AlertCircle } from "lucide-react"; +import { useState, useRef } from "react"; +import { Play, Loader2, Workflow, Search, ChevronDown } from "lucide-react"; import { AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; import type { WorkflowConfig } from "../../lib/types"; type WorkflowsAccordionProps = { sessionPhase?: string; activeWorkflow: string | null; selectedWorkflow: string; - pendingWorkflow: WorkflowConfig | null; workflowActivating: boolean; ootbWorkflows: WorkflowConfig[]; isExpanded: boolean; onWorkflowChange: (value: string) => void; - onActivateWorkflow: () => void; onResume?: () => void; }; @@ -25,16 +25,64 @@ export function WorkflowsAccordion({ sessionPhase, activeWorkflow, selectedWorkflow, - pendingWorkflow, workflowActivating, ootbWorkflows, isExpanded, onWorkflowChange, - onActivateWorkflow, onResume, }: WorkflowsAccordionProps) { + const [workflowSearch, setWorkflowSearch] = useState(""); + const [popoverOpen, setPopoverOpen] = useState(false); + const searchInputRef = useRef(null); const isSessionStopped = sessionPhase === 'Stopped' || sessionPhase === 'Error' || sessionPhase === 'Completed'; + // Filter workflows based on search query + const filteredWorkflows = ootbWorkflows + .filter((workflow) => { + if (!workflowSearch) return true; + const searchLower = workflowSearch.toLowerCase(); + return ( + workflow.name.toLowerCase().includes(searchLower) || + workflow.description.toLowerCase().includes(searchLower) + ); + }) + .sort((a, b) => a.name.localeCompare(b.name)); // Sort alphabetically by display name + + // Filter for general chat based on search + const showGeneralChat = !workflowSearch || + "general chat".includes(workflowSearch.toLowerCase()) || + "A general chat session with no structured workflow.".toLowerCase().includes(workflowSearch.toLowerCase()); + + // Filter for custom workflow based on search + const showCustomWorkflow = !workflowSearch || + "custom workflow".toLowerCase().includes(workflowSearch.toLowerCase()) || + "load a workflow from a custom git repository".toLowerCase().includes(workflowSearch.toLowerCase()); + + // Get display info for selected workflow + const getSelectedWorkflowInfo = () => { + if (selectedWorkflow === "none") { + return { + name: "General chat", + description: "A general chat session with no structured workflow." + }; + } + if (selectedWorkflow === "custom") { + return { + name: "Custom workflow...", + description: "Load a workflow from a custom Git repository" + }; + } + const workflow = ootbWorkflows.find(w => w.id === selectedWorkflow); + return workflow + ? { name: workflow.name, description: workflow.description } + : { name: "Select workflow...", description: "" }; + }; + + const handleWorkflowSelect = (value: string) => { + onWorkflowChange(value); + setPopoverOpen(false); + }; + return ( @@ -72,102 +120,147 @@ export function WorkflowsAccordion({ ) : (
- {/* Workflow selector - always visible except when activating */} - {!workflowActivating && ( - <> -

- Workflows provide agents with pre-defined context and structured steps to follow. -

- -
- setWorkflowSearch(e.target.value)} + className="pl-8 h-9" + onKeyDown={(e) => { + // Prevent popover from closing on keyboard interaction + e.stopPropagation(); + }} + /> +
+
+ + {/* Workflow items */} +
+ {showGeneralChat && ( + <> + + {filteredWorkflows.length > 0 &&
} + + )} + {filteredWorkflows.map((workflow) => ( + + ))} + {(showGeneralChat || filteredWorkflows.length > 0) && showCustomWorkflow && ( +
+ )} + {showCustomWorkflow && ( +
- - {/* Show workflow preview and activate/switch button */} - {pendingWorkflow && ( - - - - Reload required - - -
-

- Please reload this chat session to switch to the new workflow. Your chat history will be preserved. -

- + + )} + {!showGeneralChat && filteredWorkflows.length === 0 && !showCustomWorkflow && ( +
+ No workflows found
- - - )} - - )} + )} +
+ + +
{/* Show active workflow info */} {activeWorkflow && !workflowActivating && ( <> )} - - {/* Show activating/switching state */} - {workflowActivating && ( - - - {activeWorkflow ? 'Switching Workflow...' : 'Activating Workflow...'} - -
-

Please wait. This may take 10-20 seconds...

-
-
-
- )}
)}
); } - diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/welcome-experience.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/welcome-experience.tsx new file mode 100644 index 000000000..f41e4440d --- /dev/null +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/welcome-experience.tsx @@ -0,0 +1,436 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { Search } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { WorkflowConfig } from "../lib/types"; + +type WelcomeExperienceProps = { + ootbWorkflows: WorkflowConfig[]; + onWorkflowSelect: (workflowId: string) => void; + onUserInteraction: () => void; + userHasInteracted: boolean; + sessionPhase?: string; + hasRealMessages: boolean; + onLoadWorkflow?: () => void; + selectedWorkflow?: string; +}; + +const WELCOME_MESSAGE = `Welcome to Ambient AI! Please select a workflow or type a message to get started.`; +const SETUP_MESSAGE = `Great! Give me a moment to get set up`; + +export function WelcomeExperience({ + ootbWorkflows, + onWorkflowSelect, + onUserInteraction, + userHasInteracted, + sessionPhase, + hasRealMessages, + onLoadWorkflow, + selectedWorkflow = "none", +}: WelcomeExperienceProps) { + const [displayedText, setDisplayedText] = useState(""); + const [isTypingComplete, setIsTypingComplete] = useState(false); + const [setupDisplayedText, setSetupDisplayedText] = useState(""); + const [isSetupTypingComplete, setIsSetupTypingComplete] = useState(false); + const [dotCount, setDotCount] = useState(0); + const [workflowSearch, setWorkflowSearch] = useState(""); + const searchInputRef = useRef(null); + + // Track if welcome experience was shown on initial load (persists even when messages appear) + // This is captured on first render - if there were no real messages initially, we show welcome + const welcomeShownOnLoadRef = useRef(null); + if (welcomeShownOnLoadRef.current === null) { + welcomeShownOnLoadRef.current = !hasRealMessages; + } + + // Use the selectedWorkflow prop to determine which workflow is currently selected + const selectedWorkflowId = selectedWorkflow !== "none" ? selectedWorkflow : null; + + // Determine if we should show workflow cards and animation + // Show animation unless we know for certain the session has already started running or user has interacted + const isRunningOrBeyond = sessionPhase === "Running" || sessionPhase === "Completed" || sessionPhase === "Failed" || sessionPhase === "Stopped"; + const shouldShowAnimation = !userHasInteracted && !hasRealMessages && !isRunningOrBeyond; + // Show workflow cards if welcome was shown on load (even if messages appear later) and session is not in terminal state + const isTerminalPhase = sessionPhase === "Completed" || sessionPhase === "Failed" || sessionPhase === "Stopped"; + const shouldShowWorkflowCards = welcomeShownOnLoadRef.current && !isTerminalPhase; + + // Streaming text effect + useEffect(() => { + if (!shouldShowAnimation) { + // Skip animation if session is already running or user has interacted + setDisplayedText(WELCOME_MESSAGE); + setIsTypingComplete(true); + return; + } + + let currentIndex = 0; + let intervalId: ReturnType | null = null; + + intervalId = setInterval(() => { + if (currentIndex < WELCOME_MESSAGE.length) { + setDisplayedText(WELCOME_MESSAGE.slice(0, currentIndex + 1)); + currentIndex++; + } else { + setIsTypingComplete(true); + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } + } + }, 25); // 25ms per character + + return () => { + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } + }; + }, [shouldShowAnimation]); + + // Setup message typing effect (after workflow selected) + useEffect(() => { + if (!selectedWorkflowId) return; + + let currentIndex = 0; + let intervalId: ReturnType | null = null; + + intervalId = setInterval(() => { + if (currentIndex < SETUP_MESSAGE.length) { + setSetupDisplayedText(SETUP_MESSAGE.slice(0, currentIndex + 1)); + currentIndex++; + } else { + setIsSetupTypingComplete(true); + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } + } + }, 25); // 25ms per character + + return () => { + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } + }; + }, [selectedWorkflowId]); + + // Animate dots after setup message completes (stop when real messages appear) + useEffect(() => { + if (!isSetupTypingComplete || hasRealMessages) return; + + let intervalId: ReturnType | null = null; + + intervalId = setInterval(() => { + setDotCount((prev) => (prev + 1) % 4); // Cycles 0, 1, 2, 3 + }, 500); // Change dot every 500ms + + return () => { + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } + }; + }, [isSetupTypingComplete, hasRealMessages]); + + const handleWorkflowSelect = (workflowId: string) => { + onWorkflowSelect(workflowId); + onUserInteraction(); + }; + + // Filter out template workflows and only show enabled ones for the welcome cards + const enabledWorkflows = ootbWorkflows + .filter((w) => { + const nameLower = (w.name || "").toLowerCase().trim(); + const idLower = (w.id || "").toLowerCase().trim(); + const isTemplate = nameLower.includes("template") || idLower.includes("template"); + return w.enabled && !isTemplate; + }) + .sort((a, b) => { + // Custom order: PRD workflows first, then the rest + const aHasPRD = a.name.toLowerCase().includes("prd"); + const bHasPRD = b.name.toLowerCase().includes("prd"); + + if (aHasPRD && !bHasPRD) return -1; + if (!aHasPRD && bHasPRD) return 1; + return 0; // Keep original order for items in the same category + }); + + + // Filter workflows based on search query (for dropdown - includes all workflows) + const filteredWorkflows = ootbWorkflows + .filter((workflow) => { + if (!workflowSearch) return true; + const searchLower = workflowSearch.toLowerCase(); + return ( + workflow.name.toLowerCase().includes(searchLower) || + workflow.description.toLowerCase().includes(searchLower) + ); + }) + .sort((a, b) => a.name.localeCompare(b.name)); // Sort alphabetically by display name + + // Filter for general chat based on search + const showGeneralChat = !workflowSearch || + "general chat".includes(workflowSearch.toLowerCase()) || + "A general chat session with no structured workflow.".toLowerCase().includes(workflowSearch.toLowerCase()); + + // Filter for custom workflow based on search + const showCustomWorkflow = !workflowSearch || + "custom workflow".toLowerCase().includes(workflowSearch.toLowerCase()) || + "load a workflow from a custom git repository".toLowerCase().includes(workflowSearch.toLowerCase()); + + return ( + <> + {/* Only show welcome experience if it was shown on initial load */} + {welcomeShownOnLoadRef.current && ( +
+ {/* Static welcome message styled like a chat message */} +
+
+ {/* Avatar */} +
+
+ AI +
+
+ + {/* Message Content */} +
+ {/* Timestamp */} +
just now
+
+ {/* Content */} +

+ {shouldShowAnimation && !isTypingComplete ? ( + <> + {displayedText.slice(0, -3)} + {displayedText.slice(-3).split('').map((char, idx) => ( + + {char} + + ))} + + ) : ( + displayedText + )} +

+
+
+
+
+ + {/* Workflow cards - show after typing completes (only for initial phases) */} + {shouldShowWorkflowCards && isTypingComplete && enabledWorkflows.length > 0 && ( +
+
+ {enabledWorkflows.map((workflow, index) => ( + { + if (selectedWorkflowId === null) { + handleWorkflowSelect(workflow.id); + } + }} + > + +

+ {workflow.name} +

+

+ {workflow.description} +

+
+
+ ))} +
+ + {/* View all workflows button */} +
+ { + if (open) { + setWorkflowSearch(""); + // Focus the search input after a brief delay to ensure it's rendered + setTimeout(() => searchInputRef.current?.focus(), 0); + } + }}> + + + + + {/* Search box */} +
+
+ + setWorkflowSearch(e.target.value)} + className="pl-8 h-9" + onKeyDown={(e) => { + // Prevent dropdown from closing on keyboard interaction + e.stopPropagation(); + }} + /> +
+
+ + {/* Workflow items */} +
+ {showGeneralChat && ( + <> + handleWorkflowSelect("none")} + disabled={selectedWorkflowId !== null} + > +
+ General chat + + A general chat session with no structured workflow. + +
+
+ {filteredWorkflows.length > 0 && } + + )} + {filteredWorkflows.map((workflow) => ( + workflow.enabled && handleWorkflowSelect(workflow.id)} + disabled={!workflow.enabled || selectedWorkflowId !== null} + > +
+ {workflow.name} + + {workflow.description} + +
+
+ ))} + {(showGeneralChat || filteredWorkflows.length > 0) && showCustomWorkflow && ( + + )} + {showCustomWorkflow && ( + handleWorkflowSelect("custom")} + disabled={selectedWorkflowId !== null} + > +
+ Custom workflow... + + Load a workflow from a custom Git repository + +
+
+ )} + {!showGeneralChat && filteredWorkflows.length === 0 && !showCustomWorkflow && ( +
+ No workflows found +
+ )} +
+
+
+ + {onLoadWorkflow && ( + + )} +
+
+ )} + + {/* Setup message after workflow selection - only show if no real messages yet */} + {selectedWorkflowId && !hasRealMessages && ( +
+
+ {/* Avatar */} +
+
+ AI +
+
+ + {/* Message Content */} +
+ {/* Timestamp */} +
just now
+
+ {/* Content */} +

+ {!isSetupTypingComplete ? ( + <> + {setupDisplayedText.slice(0, -3)} + {setupDisplayedText.slice(-3).split('').map((char, idx) => ( + + {char} + + ))} + + ) : ( + <> + {setupDisplayedText} + {".".repeat(dotCount)} + + )} +

+
+
+
+
+ )} +
+ )} + + ); +} + diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/hooks/use-workflow-management.ts b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/hooks/use-workflow-management.ts index 654823911..391aeefe5 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/hooks/use-workflow-management.ts +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/hooks/use-workflow-management.ts @@ -1,18 +1,21 @@ "use client"; import { useState, useCallback } from "react"; -import { successToast, errorToast } from "@/hooks/use-toast"; +import { errorToast } from "@/hooks/use-toast"; +import { useSessionQueue } from "@/hooks/use-session-queue"; import type { WorkflowConfig } from "../lib/types"; type UseWorkflowManagementProps = { projectName: string; sessionName: string; + sessionPhase?: string; onWorkflowActivated?: () => void; }; export function useWorkflowManagement({ projectName, sessionName, + sessionPhase, onWorkflowActivated, }: UseWorkflowManagementProps) { const [selectedWorkflow, setSelectedWorkflow] = useState("none"); @@ -20,26 +23,46 @@ export function useWorkflowManagement({ const [activeWorkflow, setActiveWorkflow] = useState(null); const [workflowActivating, setWorkflowActivating] = useState(false); + // Use session queue for workflow persistence + const sessionQueue = useSessionQueue(projectName, sessionName); + // Set pending workflow (user selected but not yet activated) const setPending = useCallback((workflow: WorkflowConfig | null) => { setPendingWorkflow(workflow); }, []); - // Activate the pending workflow - const activateWorkflow = useCallback(async () => { - if (!pendingWorkflow) return false; + // Activate the pending workflow (or a workflow passed directly) + const activateWorkflow = useCallback(async (workflowToActivate?: WorkflowConfig, currentPhase?: string) => { + const workflow = workflowToActivate || pendingWorkflow; + if (!workflow) return false; + + const phase = currentPhase || sessionPhase; + + // If session is not yet running, queue the workflow for later + // This includes: undefined (loading), "Pending", "Creating", or any other non-Running state + if (!phase || phase !== "Running") { + sessionQueue.setWorkflow({ + id: workflow.id, + gitUrl: workflow.gitUrl, + branch: workflow.branch, + path: workflow.path || "", + }); + setSelectedWorkflow(workflow.id); + setWorkflowActivating(true); // Show loading state + return true; // Don't return false - we've queued it successfully + } setWorkflowActivating(true); try { - // 1. Update CR with workflow configuration + // Update CR with workflow configuration const response = await fetch(`/api/projects/${projectName}/agentic-sessions/${sessionName}/workflow`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - gitUrl: pendingWorkflow.gitUrl, - branch: pendingWorkflow.branch, - path: pendingWorkflow.path || "", + gitUrl: workflow.gitUrl, + branch: workflow.branch, + path: workflow.path || "", }), }); @@ -48,28 +71,26 @@ export function useWorkflowManagement({ throw new Error(errorData.error || "Failed to update workflow"); } - // Note: Workflow clone and restart handled by operator - // Initial workflow prompt auto-executed via AG-UI pattern (POST /agui/run) - - successToast(`Activating workflow: ${pendingWorkflow.name}`); - setActiveWorkflow(pendingWorkflow.id); + setActiveWorkflow(workflow.id); setPendingWorkflow(null); + sessionQueue.clearWorkflow(); // Wait for restart to complete (give runner time to clone and restart) await new Promise(resolve => setTimeout(resolve, 3000)); onWorkflowActivated?.(); - successToast("Workflow activated successfully"); return true; } catch (error) { console.error("Failed to activate workflow:", error); errorToast(error instanceof Error ? error.message : "Failed to activate workflow"); + sessionQueue.clearWorkflow(); + setWorkflowActivating(false); return false; } finally { setWorkflowActivating(false); } - }, [pendingWorkflow, projectName, sessionName, onWorkflowActivated]); + }, [pendingWorkflow, projectName, sessionName, sessionPhase, sessionQueue, onWorkflowActivated]); // Handle workflow selection change const handleWorkflowChange = useCallback((value: string, ootbWorkflows: WorkflowConfig[], onCustom: () => void) => { @@ -77,28 +98,29 @@ export function useWorkflowManagement({ if (value === "none") { setPendingWorkflow(null); - return; + return null; } if (value === "custom") { onCustom(); - return; + return null; } // Find the selected workflow from OOTB workflows const workflow = ootbWorkflows.find(w => w.id === value); if (!workflow) { errorToast(`Workflow ${value} not found`); - return; + return null; } if (!workflow.enabled) { errorToast(`Workflow ${workflow.name} is not yet available`); - return; + return null; } // Set as pending (user must click Activate) setPendingWorkflow(workflow); + return workflow; }, []); // Set custom workflow as pending @@ -120,6 +142,7 @@ export function useWorkflowManagement({ setSelectedWorkflow, pendingWorkflow, setPending, + queuedWorkflow: sessionQueue.workflow, activeWorkflow, setActiveWorkflow, workflowActivating, diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx index 8c964eeb2..1776504c9 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx @@ -16,8 +16,6 @@ import { Cloud, FolderSync, Download, - LibraryBig, - MessageSquare, SlidersHorizontal, ArrowLeft, } from "lucide-react"; @@ -73,11 +71,12 @@ import { WorkflowsAccordion } from "./components/accordions/workflows-accordion" import { RepositoriesAccordion } from "./components/accordions/repositories-accordion"; import { ArtifactsAccordion } from "./components/accordions/artifacts-accordion"; import { McpIntegrationsAccordion } from "./components/accordions/mcp-integrations-accordion"; - +import { WelcomeExperience } from "./components/welcome-experience"; // Extracted hooks and utilities import { useGitOperations } from "./hooks/use-git-operations"; import { useWorkflowManagement } from "./hooks/use-workflow-management"; import { useFileOperations } from "./hooks/use-file-operations"; +import { useSessionQueue } from "@/hooks/use-session-queue"; import type { DirectoryOption, DirectoryRemote } from "./lib/types"; import type { MessageObject, ToolUseMessages, HierarchicalToolMessage } from "@/types/agentic-session"; @@ -150,12 +149,12 @@ export default function ProjectSessionDetailPage({ const [sessionName, setSessionName] = useState(""); const [chatInput, setChatInput] = useState(""); const [backHref, setBackHref] = useState(null); - const [openAccordionItems, setOpenAccordionItems] = useState(["workflows"]); + const [openAccordionItems, setOpenAccordionItems] = useState([]); const [contextModalOpen, setContextModalOpen] = useState(false); const [uploadModalOpen, setUploadModalOpen] = useState(false); const [repoChanging, setRepoChanging] = useState(false); - const [firstMessageLoaded, setFirstMessageLoaded] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [userHasInteracted, setUserHasInteracted] = useState(false); // Directory browser state (unified for artifacts, repos, and workflow) const [selectedDirectory, setSelectedDirectory] = useState({ @@ -183,6 +182,9 @@ export default function ProjectSessionDetailPage({ }); }, [params]); + // Session queue hook (localStorage-backed) + const sessionQueue = useSessionQueue(projectName, sessionName); + // React Query hooks const { data: session, @@ -256,9 +258,89 @@ export default function ProjectSessionDetailPage({ const workflowManagement = useWorkflowManagement({ projectName, sessionName, + sessionPhase: session?.status?.phase, onWorkflowActivated: refetchSession, }); + // Poll session status when workflow is queued + useEffect(() => { + if (!workflowManagement.queuedWorkflow) return; + + const phase = session?.status?.phase; + + // If already running, we'll process workflow in the next effect + if (phase === "Running") return; + + // Poll every 2 seconds to check if session is ready + const pollInterval = setInterval(() => { + refetchSession(); + }, 2000); + + return () => clearInterval(pollInterval); + }, [workflowManagement.queuedWorkflow, session?.status?.phase, refetchSession]); + + // Process queued workflow when session becomes Running + useEffect(() => { + const phase = session?.status?.phase; + const queuedWorkflow = workflowManagement.queuedWorkflow; + if (phase === "Running" && queuedWorkflow && !queuedWorkflow.activatedAt) { + // Session is now running, activate the queued workflow + workflowManagement.activateWorkflow({ + id: queuedWorkflow.id, + name: "Queued workflow", + description: "", + gitUrl: queuedWorkflow.gitUrl, + branch: queuedWorkflow.branch, + path: queuedWorkflow.path, + enabled: true, + }, phase); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session?.status?.phase, workflowManagement.queuedWorkflow]); + + // Poll session status when messages are queued + useEffect(() => { + const queuedMessages = sessionQueue.messages.filter(m => !m.sentAt); + if (queuedMessages.length === 0) return; + + const phase = session?.status?.phase; + + // If already running, we'll process messages in the next effect + if (phase === "Running") return; + + // Poll every 2 seconds to check if session is ready + const pollInterval = setInterval(() => { + refetchSession(); + }, 2000); + + return () => clearInterval(pollInterval); + }, [sessionQueue.messages, session?.status?.phase, refetchSession]); + + // Process queued messages when session becomes Running + useEffect(() => { + const phase = session?.status?.phase; + const unsentMessages = sessionQueue.messages.filter(m => !m.sentAt); + + if (phase === "Running" && unsentMessages.length > 0) { + // Session is now running, send all queued messages + const processMessages = async () => { + for (const messageItem of unsentMessages) { + try { + await aguiSendMessage(messageItem.content); + sessionQueue.markMessageSent(messageItem.id); + // Small delay between messages to avoid overwhelming the system + await new Promise(resolve => setTimeout(resolve, 100)); + } catch (err) { + errorToast(err instanceof Error ? err.message : "Failed to send queued message"); + } + } + }; + + processMessages(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session?.status?.phase, sessionQueue.messages.length]); + // Repo management mutations const addRepoMutation = useMutation({ mutationFn: async (repo: { url: string; branch: string }) => { @@ -503,35 +585,17 @@ export default function ProjectSessionDetailPage({ // Track if we've already initialized from session const initializedFromSessionRef = useRef(false); + const workflowLoadedFromSessionRef = useRef(false); - // Track when first message loads - useEffect(() => { - if (aguiState.messages && aguiState.messages.length > 0 && !firstMessageLoaded) { - setFirstMessageLoaded(true); - } - }, [aguiState.messages, firstMessageLoaded]); + // Note: userHasInteracted is only set when: + // 1. User explicitly selects a workflow (handleWelcomeWorkflowSelect -> onUserInteraction) + // 2. User sends a message (sendChat sets it to true) + // It should NOT be set automatically when backend messages arrive - // Load active workflow and remotes from session + // Load remotes from session annotations (one-time initialization) useEffect(() => { if (initializedFromSessionRef.current || !session) return; - if (session.spec?.activeWorkflow && ootbWorkflows.length === 0) { - return; - } - - if (session.spec?.activeWorkflow) { - const gitUrl = session.spec.activeWorkflow.gitUrl; - const matchingWorkflow = ootbWorkflows.find((w) => w.gitUrl === gitUrl); - if (matchingWorkflow) { - workflowManagement.setActiveWorkflow(matchingWorkflow.id); - workflowManagement.setSelectedWorkflow(matchingWorkflow.id); - } else { - workflowManagement.setActiveWorkflow("custom"); - workflowManagement.setSelectedWorkflow("custom"); - } - } - - // Load remotes from annotations const annotations = session.metadata?.annotations || {}; const remotes: Record = {}; @@ -551,7 +615,7 @@ export default function ProjectSessionDetailPage({ setDirectoryRemotes(remotes); initializedFromSessionRef.current = true; - }, [session, ootbWorkflows, workflowManagement]); + }, [session]); // Compute directory options const directoryOptions = useMemo(() => { @@ -589,9 +653,18 @@ export default function ProjectSessionDetailPage({ // Workflow change handler const handleWorkflowChange = (value: string) => { - workflowManagement.handleWorkflowChange(value, ootbWorkflows, () => + const workflow = workflowManagement.handleWorkflowChange(value, ootbWorkflows, () => setCustomWorkflowDialogOpen(true), ); + // Automatically trigger activation with the workflow directly (avoids state timing issues) + if (workflow) { + workflowManagement.activateWorkflow(workflow, session?.status?.phase); + } + }; + + // Handle workflow selection from welcome experience + const handleWelcomeWorkflowSelect = (workflowId: string) => { + handleWorkflowChange(workflowId); }; // Convert AG-UI messages to display format with hierarchical tool call rendering @@ -954,6 +1027,59 @@ export default function ProjectSessionDetailPage({ aguiState.pendingChildren, // CRITICAL: Include so UI updates when children finish ]); + // Check if there are any real messages (user or assistant messages, not just system) + const hasRealMessages = useMemo(() => { + return streamMessages.some( + (msg) => msg.type === "user_message" || msg.type === "agent_message" + ); + }, [streamMessages]); + + // Clear queued messages when first agent response arrives + useEffect(() => { + const sentMessages = sessionQueue.messages.filter(m => m.sentAt); + if (sentMessages.length > 0 && streamMessages.length > 0) { + // Check if there's at least one agent message (response to our queued messages) + const hasAgentResponse = streamMessages.some( + msg => msg.type === "agent_message" || msg.type === "tool_use_messages" + ); + + if (hasAgentResponse) { + sessionQueue.clearMessages(); + } + } + }, [sessionQueue, streamMessages]); + + // Load workflow from session when session data and workflows are available + // Syncs the workflow panel with the workflow reported by the API + useEffect(() => { + if (workflowLoadedFromSessionRef.current || !session) return; + if (session.spec?.activeWorkflow && ootbWorkflows.length === 0) return; + + // Sync workflow from session whenever it's set in the API + if (session.spec?.activeWorkflow) { + // Match by path (e.g., "workflows/spec-kit") - this uniquely identifies each OOTB workflow + // Don't match by gitUrl since all OOTB workflows share the same repo URL + const activePath = session.spec.activeWorkflow.path; + const matchingWorkflow = ootbWorkflows.find((w) => w.path === activePath); + if (matchingWorkflow) { + workflowManagement.setActiveWorkflow(matchingWorkflow.id); + workflowManagement.setSelectedWorkflow(matchingWorkflow.id); + // Mark as interacted for existing sessions with messages + if (hasRealMessages) { + setUserHasInteracted(true); + } + } else { + // No matching OOTB workflow found - treat as custom workflow + workflowManagement.setActiveWorkflow("custom"); + workflowManagement.setSelectedWorkflow("custom"); + if (hasRealMessages) { + setUserHasInteracted(true); + } + } + workflowLoadedFromSessionRef.current = true; + } + }, [session, ootbWorkflows, workflowManagement, hasRealMessages]); + // Auto-refresh artifacts when messages complete // UX improvement: Automatically refresh the artifacts panel when Claude writes new files, // so users can see their changes immediately without manually clicking the refresh button @@ -1024,7 +1150,6 @@ export default function ProjectSessionDetailPage({ } }; }, [session?.status?.phase, refetchArtifactsFiles]); - // Session action handlers const handleStop = () => { stopMutation.mutate( @@ -1086,6 +1211,18 @@ export default function ProjectSessionDetailPage({ const finalMessage = chatInput.trim(); setChatInput(""); + // Mark user interaction when they send first message + setUserHasInteracted(true); + + const phase = session?.status?.phase; + + // If session is not yet running, queue the message for later + // This includes: undefined (loading), "Pending", "Creating", or any other non-Running state + if (!phase || phase !== "Running") { + sessionQueue.addMessage(finalMessage); + return; + } + try { await aguiSendMessage(finalMessage); } catch (err) { @@ -1297,25 +1434,7 @@ export default function ProjectSessionDetailPage({ - {/* Blocking overlay when first message hasn't loaded and session is pending */} - {!firstMessageLoaded && - session?.status?.phase === "Pending" && ( -
-
- -
- -

No context yet

-
-

- Context will appear once the session starts... -

-
-
- )} -
+
@@ -1761,21 +1878,6 @@ export default function ProjectSessionDetailPage({
- {/* Workflow activation overlay */} - {workflowManagement.workflowActivating && ( -
- - - Activating Workflow... - -

- The new workflow is being loaded. Please wait... -

-
-
-
- )} - {/* Repository change overlay */} {repoChanging && (
@@ -1794,26 +1896,7 @@ export default function ProjectSessionDetailPage({
)} - {/* Session starting overlay */} - {!firstMessageLoaded && - session?.status?.phase === "Pending" && ( -
-
- -
- -

No messages yet

-
-

- Messages will appear once the session starts... -

-
-
- )} - -
+
setUserHasInteracted(true)} + userHasInteracted={userHasInteracted} + sessionPhase={session?.status?.phase} + hasRealMessages={hasRealMessages} + onLoadWorkflow={() => setCustomWorkflowDialogOpen(true)} + selectedWorkflow={workflowManagement.selectedWorkflow} + /> + } />
diff --git a/components/frontend/src/app/projects/[name]/sessions/new/page.tsx b/components/frontend/src/app/projects/[name]/sessions/new/page.tsx index 5ec1ad2b2..147d651a6 100644 --- a/components/frontend/src/app/projects/[name]/sessions/new/page.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/new/page.tsx @@ -14,7 +14,7 @@ import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, For import { Textarea } from "@/components/ui/textarea"; import type { CreateAgenticSessionRequest } from "@/types/agentic-session"; import { Checkbox } from "@/components/ui/checkbox"; -import { successToast, errorToast } from "@/hooks/use-toast"; +import { errorToast } from "@/hooks/use-toast"; import { Breadcrumbs } from "@/components/breadcrumbs"; import { RepositoryDialog } from "./repository-dialog"; import { RepositoryList } from "./repository-list"; @@ -130,7 +130,6 @@ export default function NewProjectSessionPage({ params }: { params: Promise<{ na { onSuccess: (session) => { const sessionName = session.metadata.name; - successToast(`Session "${sessionName}" created successfully`); router.push(`/projects/${encodeURIComponent(projectName)}/sessions/${sessionName}`); }, onError: (error) => { diff --git a/components/frontend/src/components/create-session-dialog.tsx b/components/frontend/src/components/create-session-dialog.tsx index d33f1baee..dcb9c5fa7 100644 --- a/components/frontend/src/components/create-session-dialog.tsx +++ b/components/frontend/src/components/create-session-dialog.tsx @@ -35,7 +35,7 @@ import { Input } from "@/components/ui/input"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import type { CreateAgenticSessionRequest } from "@/types/agentic-session"; import { useCreateSession } from "@/services/queries/use-sessions"; -import { successToast, errorToast } from "@/hooks/use-toast"; +import { errorToast } from "@/hooks/use-toast"; const models = [ { value: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" }, @@ -97,7 +97,6 @@ export function CreateSessionDialog({ { onSuccess: (session) => { const sessionName = session.metadata.name; - successToast(`Session "${sessionName}" created successfully`); setOpen(false); form.reset(); router.push(`/projects/${encodeURIComponent(projectName)}/sessions/${sessionName}`); diff --git a/components/frontend/src/components/session/MessagesTab.tsx b/components/frontend/src/components/session/MessagesTab.tsx index 014defb4e..3e45d60b6 100644 --- a/components/frontend/src/components/session/MessagesTab.tsx +++ b/components/frontend/src/components/session/MessagesTab.tsx @@ -3,7 +3,6 @@ import React, { useState, useRef, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { MessageSquare, Loader2, Settings, Terminal, Users } from "lucide-react"; import { StreamMessage } from "@/components/ui/stream-message"; import { LoadingDots } from "@/components/ui/message"; @@ -16,6 +15,7 @@ import { import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import type { AgenticSession, MessageObject, ToolUseMessages } from "@/types/agentic-session"; import type { WorkflowMetadata } from "@/app/projects/[name]/sessions/[sessionName]/lib/types"; +import type { QueuedMessageItem } from "@/hooks/use-session-queue"; export type MessagesTabProps = { session: AgenticSession; @@ -29,17 +29,24 @@ export type MessagesTabProps = { onContinue: () => void; workflowMetadata?: WorkflowMetadata; onCommandClick?: (slashCommand: string) => void; - isRunActive?: boolean; // NEW: Track if agent is actively processing + isRunActive?: boolean; // Track if agent is actively processing + showWelcomeExperience?: boolean; + welcomeExperienceComponent?: React.ReactNode; + activeWorkflow?: string | null; // Track if workflow has been selected + userHasInteracted?: boolean; // Track if user has sent any messages + queuedMessages?: QueuedMessageItem[]; // Messages queued while session wasn't running + hasRealMessages?: boolean; // Track if there are real user/agent messages }; -const MessagesTab: React.FC = ({ session, streamMessages, chatInput, setChatInput, onSendChat, onInterrupt, onEndSession, onGoToResults, onContinue, workflowMetadata, onCommandClick, isRunActive = false }) => { - const [sendingChat, setSendingChat] = useState(false); +const MessagesTab: React.FC = ({ session, streamMessages, chatInput, setChatInput, onSendChat, onInterrupt, onEndSession, onGoToResults, onContinue, workflowMetadata, onCommandClick, isRunActive = false, showWelcomeExperience, welcomeExperienceComponent, activeWorkflow, userHasInteracted = false, queuedMessages = [], hasRealMessages = false }) => { const [interrupting, setInterrupting] = useState(false); const [ending, setEnding] = useState(false); + const [sendingChat, setSendingChat] = useState(false); const [showSystemMessages, setShowSystemMessages] = useState(false); const [agentsPopoverOpen, setAgentsPopoverOpen] = useState(false); const [commandsPopoverOpen, setCommandsPopoverOpen] = useState(false); + const [waitingDotCount, setWaitingDotCount] = useState(0); // Autocomplete state const [autocompleteOpen, setAutocompleteOpen] = useState(false); @@ -56,8 +63,8 @@ const MessagesTab: React.FC = ({ session, streamMessages, chat const phase = session?.status?.phase || ""; const isInteractive = session?.spec?.interactive; - // Only show chat interface when session is interactive AND in Running state - const showChatInterface = isInteractive && phase === "Running"; + // Show chat interface when session is interactive AND (in Running state OR showing welcome experience) + const showChatInterface = isInteractive && (phase === "Running" || showWelcomeExperience); // Determine if session is in a terminal state const isTerminalState = ["Completed", "Failed", "Stopped"].includes(phase); @@ -112,6 +119,18 @@ const MessagesTab: React.FC = ({ session, streamMessages, chat scrollToBottom(); }, []); + // Animate dots for "Please wait one moment" message + useEffect(() => { + const unsentCount = queuedMessages.filter(m => !m.sentAt).length; + if (unsentCount === 0) return; + + const interval = setInterval(() => { + setWaitingDotCount((prev) => (prev + 1) % 4); // Cycles 0, 1, 2, 3 + }, 500); // Change dot every 500ms + + return () => clearInterval(interval); + }, [queuedMessages]); + // Click outside to close autocomplete useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -268,38 +287,79 @@ const MessagesTab: React.FC = ({ session, streamMessages, chat } }; + // Determine if we should show messages + // Messages should be hidden until workflow is selected OR user sends a message when welcome experience is active + // BUT always show messages if there are real messages (e.g., when loading an existing session with messages) + const shouldShowMessages = !showWelcomeExperience || activeWorkflow || userHasInteracted || hasRealMessages; + return (
- {filteredMessages.map((m, idx) => ( + {/* Show welcome experience if active - let the component handle its own visibility logic */} + {showWelcomeExperience && welcomeExperienceComponent} + + {/* Show filtered messages only if workflow is selected or welcome experience is not shown */} + {shouldShowMessages && filteredMessages.map((m, idx) => ( ))} - {/* Show loading indicator when agent is actively processing */} - {isRunActive && filteredMessages.length > 0 && ( -
- + {/* Show queued messages as regular user messages (only if not yet sent) */} + {queuedMessages.length > 0 && queuedMessages.filter(m => !m.sentAt).map((item) => { + const queuedUserMessage: MessageObject = { + type: "user_message", + content: { type: "text_block", text: item.content }, + timestamp: new Date(item.timestamp).toISOString(), + }; + return ( + + ); + })} + + {/* Show "Please wait" message while queued messages are waiting */} + {queuedMessages.filter(m => !m.sentAt).length > 0 && ( +
+
+ {/* Avatar */} +
+
+ AI +
+
+ + {/* Message Content */} +
+ {/* Timestamp */} +
just now
+
+ {/* Content */} +

+ Please wait one moment{".".repeat(waitingDotCount)} +

+
+
+
)} - {filteredMessages.length === 0 && isCreating && ( -
- - - Starting Session... - -

Setting up your workspace and initializing the agent. Messages will appear here once the session is ready.

-
-
+ {/* Show loading indicator when agent is actively processing */} + {shouldShowMessages && isRunActive && filteredMessages.length > 0 && ( +
+
)} - {filteredMessages.length === 0 && !isCreating && ( -
+ {/* Show empty state only if no welcome experience and no messages */} + {!showWelcomeExperience && filteredMessages.length === 0 && ( +

No messages yet

diff --git a/components/frontend/src/components/ui/card.tsx b/components/frontend/src/components/ui/card.tsx index 652473628..740733bbd 100644 --- a/components/frontend/src/components/ui/card.tsx +++ b/components/frontend/src/components/ui/card.tsx @@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {

+
         )}
@@ -63,7 +63,7 @@ const defaultComponents: Components = {
     );
   },
   p: ({ children }) => (
-    

{children}

+

{children}

), h1: ({ children }) => (

{children}

@@ -212,11 +212,11 @@ export const Message = React.forwardRef(
)}
{/* Content */} -
+
{isLoading ? (
{content}
diff --git a/components/frontend/src/components/ui/popover.tsx b/components/frontend/src/components/ui/popover.tsx index dd3aab353..ce3532000 100644 --- a/components/frontend/src/components/ui/popover.tsx +++ b/components/frontend/src/components/ui/popover.tsx @@ -1,11 +1,13 @@ "use client" import * as React from "react" +import { createPortal } from "react-dom" import { cn } from "@/lib/utils" interface PopoverContextType { open: boolean setOpen: (open: boolean) => void + triggerRef: React.RefObject } const PopoverContext = React.createContext(undefined) @@ -26,13 +28,14 @@ interface PopoverProps { export function Popover({ children, open: controlledOpen, onOpenChange }: PopoverProps) { const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false) + const triggerRef = React.useRef(null) const open = controlledOpen !== undefined ? controlledOpen : uncontrolledOpen const setOpen = onOpenChange || setUncontrolledOpen return ( - -
+ +
{children}
@@ -46,7 +49,7 @@ interface PopoverTriggerProps { } export function PopoverTrigger({ children, asChild, className }: PopoverTriggerProps) { - const { open, setOpen } = usePopoverContext() + const { open, setOpen, triggerRef } = usePopoverContext() const handleClick = (e: React.MouseEvent) => { e.stopPropagation() @@ -54,14 +57,15 @@ export function PopoverTrigger({ children, asChild, className }: PopoverTriggerP } if (asChild && React.isValidElement(children)) { - return React.cloneElement(children as React.ReactElement>, { + return React.cloneElement(children as React.ReactElement & { ref?: React.Ref }>, { + ref: triggerRef as React.Ref, onClick: handleClick, className: cn((children as React.ReactElement>).props?.className, className), }) } return ( - ) @@ -72,22 +76,77 @@ interface PopoverContentProps { className?: string align?: "start" | "center" | "end" side?: "top" | "right" | "bottom" | "left" + sideOffset?: number } export function PopoverContent({ children, className, align = "center", - side = "bottom" + side = "bottom", + sideOffset = 0 }: PopoverContentProps) { - const { open, setOpen } = usePopoverContext() + const { open, setOpen, triggerRef } = usePopoverContext() const contentRef = React.useRef(null) + const [position, setPosition] = React.useState<{ top: number; left: number }>({ top: 0, left: 0 }) + const [mounted, setMounted] = React.useState(false) + + React.useEffect(() => { + setMounted(true) + }, []) + + React.useEffect(() => { + if (!open || !triggerRef.current) return + + const updatePosition = () => { + const triggerRect = triggerRef.current!.getBoundingClientRect() + let top = 0 + let left = 0 + + // Calculate vertical position based on side + if (side === "bottom") { + top = triggerRect.bottom + sideOffset + } else if (side === "top") { + top = triggerRect.top - sideOffset + } else if (side === "left" || side === "right") { + top = triggerRect.top + } + + // Calculate horizontal position based on align + if (align === "start") { + left = triggerRect.left + } else if (align === "center") { + left = triggerRect.left + triggerRect.width / 2 + } else if (align === "end") { + left = triggerRect.right + } + + // Adjust for side positioning + if (side === "left") { + left = triggerRect.left - sideOffset + } else if (side === "right") { + left = triggerRect.right + sideOffset + } + + setPosition({ top, left }) + } + + updatePosition() + window.addEventListener("scroll", updatePosition, true) + window.addEventListener("resize", updatePosition) + + return () => { + window.removeEventListener("scroll", updatePosition, true) + window.removeEventListener("resize", updatePosition) + } + }, [open, triggerRef, side, align, sideOffset]) React.useEffect(() => { if (!open) return const handleClickOutside = (e: MouseEvent) => { - if (contentRef.current && !contentRef.current.contains(e.target as Node)) { + if (contentRef.current && !contentRef.current.contains(e.target as Node) && + triggerRef.current && !triggerRef.current.contains(e.target as Node)) { setOpen(false) } } @@ -105,35 +164,39 @@ export function PopoverContent({ document.removeEventListener("mousedown", handleClickOutside) document.removeEventListener("keydown", handleEscape) } - }, [open, setOpen]) + }, [open, setOpen, triggerRef]) - if (!open) return null + if (!open || !mounted) return null - const alignmentClasses = { - start: "left-0", - center: "left-1/2 -translate-x-1/2", - end: "right-0", + const getTransformOrigin = () => { + if (side === "bottom") return "top" + if (side === "top") return "bottom" + if (side === "left") return "right" + if (side === "right") return "left" + return "top" } - const sideClasses = { - top: "bottom-full mb-2", - right: "left-full ml-2 top-0", - bottom: "top-full mt-2", - left: "right-full mr-2 top-0", - } - - return ( + const content = (
{children}
) + + return createPortal(content, document.body) } diff --git a/components/frontend/src/components/ui/tool-message.tsx b/components/frontend/src/components/ui/tool-message.tsx index efbc9a67c..f48b6ffe5 100644 --- a/components/frontend/src/components/ui/tool-message.tsx +++ b/components/frontend/src/components/ui/tool-message.tsx @@ -386,7 +386,7 @@ const ChildToolCall: React.FC = ({ toolUseBlock, resultBlock const isError = resultBlock?.is_error === true; const isSuccess = hasActualResult && !isError; - const isPending = !hasActualResult; + const isPending = !hasActualResult && !isError; const toolName = toolUseBlock?.name || "unknown_tool"; @@ -497,8 +497,8 @@ export const ToolMessage = React.forwardRef( // For tool calls/results, show collapsible interface const toolName = formatToolName(toolUseBlock?.name); - const isLoading = isToolCall; // Tool call without result is loading const isError = toolResultBlock?.is_error === true; + const isLoading = isToolCall && !isError; // Tool call without result is loading, unless there's an error const isSuccess = isToolResult && !isError; // Subagent detection and data diff --git a/components/frontend/src/hooks/use-agui-stream.ts b/components/frontend/src/hooks/use-agui-stream.ts index 1c8839a61..a2152f3ae 100644 --- a/components/frontend/src/hooks/use-agui-stream.ts +++ b/components/frontend/src/hooks/use-agui-stream.ts @@ -761,7 +761,7 @@ export function useAGUIStream(options: UseAGUIStreamOptions): UseAGUIStreamRetur throw error } }, - [projectName, sessionName, state.threadId, state.runId, state.status, processEvent, connect], + [projectName, sessionName, state.threadId, state.runId, state.status, connect], ) // Auto-connect on mount if enabled (client-side only) diff --git a/components/frontend/src/hooks/use-local-storage.ts b/components/frontend/src/hooks/use-local-storage.ts index 93a4ac4c2..74e858ec0 100644 --- a/components/frontend/src/hooks/use-local-storage.ts +++ b/components/frontend/src/hooks/use-local-storage.ts @@ -42,7 +42,12 @@ export function useLocalStorage( setStoredValue(valueToStore); if (typeof window !== 'undefined') { - window.localStorage.setItem(key, JSON.stringify(valueToStore)); + // Clean up key if value is null or undefined + if (valueToStore === null || valueToStore === undefined) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } } } catch (error) { console.warn(`Error setting localStorage key "${key}":`, error); diff --git a/components/frontend/src/hooks/use-session-queue.ts b/components/frontend/src/hooks/use-session-queue.ts new file mode 100644 index 000000000..46d938991 --- /dev/null +++ b/components/frontend/src/hooks/use-session-queue.ts @@ -0,0 +1,228 @@ +/** + * useSessionQueue hook + * + * Manages a localStorage-backed queue for messages and workflows that need to be + * processed when a session transitions from Pending/Creating to Running state. + * + * This allows users to: + * 1. Queue messages while a session is starting up + * 2. Queue workflow selections before session is ready + * 3. Automatically process queued items when session becomes Running + */ + +import { useState, useCallback, useEffect } from 'react'; + +// Types +export interface QueuedMessageItem { + id: string; + content: string; + timestamp: number; + sentAt?: number; +} + +export interface QueuedWorkflowItem { + id: string; + gitUrl: string; + branch: string; + path: string; + timestamp: number; + activatedAt?: number; +} + +export interface QueueMetadata { + sessionPhase?: string; + processing?: boolean; + lastProcessedAt?: number; + retryCount?: number; + errorCount?: number; + lastError?: string; +} + +interface UseSessionQueueReturn { + // Message queue operations + messages: QueuedMessageItem[]; + addMessage: (content: string) => void; + markMessageSent: (messageId: string) => void; + clearMessages: () => void; + + // Workflow queue operations + workflow: QueuedWorkflowItem | null; + setWorkflow: (workflow: Omit) => void; + markWorkflowActivated: (workflowId: string) => void; + clearWorkflow: () => void; + + // Metadata operations + metadata: QueueMetadata; + updateMetadata: (updates: Partial) => void; +} + +// Constants +const MAX_MESSAGES = 100; +const MESSAGE_RETENTION_MS = 24 * 60 * 60 * 1000; // 24 hours + +// Generate unique ID +function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +} + +// Filter out old messages +function filterOldMessages(messages: QueuedMessageItem[]): QueuedMessageItem[] { + const now = Date.now(); + return messages.filter(msg => (now - msg.timestamp) < MESSAGE_RETENTION_MS); +} + +/** + * Hook to manage session queue (messages and workflows) + */ +export function useSessionQueue( + projectName: string, + sessionName: string +): UseSessionQueueReturn { + const messagesKey = `vteam:queue:${projectName}:${sessionName}:messages`; + const workflowKey = `vteam:queue:${projectName}:${sessionName}:workflow`; + const metadataKey = `vteam:queue:${projectName}:${sessionName}:metadata`; + + // Initialize state from localStorage + const [messages, setMessages] = useState(() => { + if (typeof window === 'undefined') return []; + try { + const stored = localStorage.getItem(messagesKey); + if (!stored) return []; + const parsed = JSON.parse(stored) as QueuedMessageItem[]; + return filterOldMessages(parsed).slice(-MAX_MESSAGES); + } catch (error) { + console.warn('Failed to load queued messages from localStorage:', error); + return []; + } + }); + + const [workflow, setWorkflowState] = useState(() => { + if (typeof window === 'undefined') return null; + try { + const stored = localStorage.getItem(workflowKey); + if (!stored) return null; + return JSON.parse(stored) as QueuedWorkflowItem; + } catch (error) { + console.warn('Failed to load queued workflow from localStorage:', error); + return null; + } + }); + + const [metadata, setMetadata] = useState(() => { + if (typeof window === 'undefined') return {}; + try { + const stored = localStorage.getItem(metadataKey); + if (!stored) return {}; + return JSON.parse(stored) as QueueMetadata; + } catch (error) { + console.warn('Failed to load queue metadata from localStorage:', error); + return {}; + } + }); + + // Persist messages to localStorage + useEffect(() => { + if (typeof window === 'undefined') return; + try { + if (messages.length === 0) { + localStorage.removeItem(messagesKey); + } else { + localStorage.setItem(messagesKey, JSON.stringify(messages)); + } + } catch (error) { + console.warn('Failed to persist messages to localStorage:', error); + } + }, [messages, messagesKey]); + + // Persist workflow to localStorage + useEffect(() => { + if (typeof window === 'undefined') return; + try { + if (workflow === null) { + localStorage.removeItem(workflowKey); + } else { + localStorage.setItem(workflowKey, JSON.stringify(workflow)); + } + } catch (error) { + console.warn('Failed to persist workflow to localStorage:', error); + } + }, [workflow, workflowKey]); + + // Persist metadata to localStorage + useEffect(() => { + if (typeof window === 'undefined') return; + try { + // Clean up if metadata is empty + if (Object.keys(metadata).length === 0) { + localStorage.removeItem(metadataKey); + } else { + localStorage.setItem(metadataKey, JSON.stringify(metadata)); + } + } catch (error) { + console.warn('Failed to persist metadata to localStorage:', error); + } + }, [metadata, metadataKey]); + + // Message operations + const addMessage = useCallback((content: string) => { + const newMessage: QueuedMessageItem = { + id: generateId(), + content, + timestamp: Date.now(), + }; + setMessages(prev => [...filterOldMessages(prev), newMessage].slice(-MAX_MESSAGES)); + }, []); + + const markMessageSent = useCallback((messageId: string) => { + setMessages(prev => + prev.map(msg => + msg.id === messageId + ? { ...msg, sentAt: Date.now() } + : msg + ) + ); + }, []); + + const clearMessages = useCallback(() => { + setMessages([]); + }, []); + + // Workflow operations + const setWorkflow = useCallback((workflowData: Omit) => { + setWorkflowState({ + ...workflowData, + timestamp: Date.now(), + }); + }, []); + + const markWorkflowActivated = useCallback((workflowId: string) => { + setWorkflowState(prev => + prev && prev.id === workflowId + ? { ...prev, activatedAt: Date.now() } + : prev + ); + }, []); + + const clearWorkflow = useCallback(() => { + setWorkflowState(null); + }, []); + + // Metadata operations + const updateMetadata = useCallback((updates: Partial) => { + setMetadata(prev => ({ ...prev, ...updates })); + }, []); + + return { + messages, + addMessage, + markMessageSent, + clearMessages, + workflow, + setWorkflow, + markWorkflowActivated, + clearWorkflow, + metadata, + updateMetadata, + }; +} + diff --git a/components/frontend/src/services/queries/use-workflows.ts b/components/frontend/src/services/queries/use-workflows.ts index 92b075598..17436bb18 100644 --- a/components/frontend/src/services/queries/use-workflows.ts +++ b/components/frontend/src/services/queries/use-workflows.ts @@ -11,7 +11,11 @@ export const workflowKeys = { export function useOOTBWorkflows(projectName?: string) { return useQuery({ queryKey: workflowKeys.ootb(projectName), - queryFn: () => workflowsApi.listOOTBWorkflows(projectName), + queryFn: async () => { + const workflows = await workflowsApi.listOOTBWorkflows(projectName); + // Return all workflows - filtering should be done at the component level if needed + return workflows; + }, enabled: !!projectName, // Only fetch when projectName is available staleTime: 5 * 60 * 1000, // 5 minutes - workflows don't change often }); diff --git a/components/frontend/tailwind.config.js b/components/frontend/tailwind.config.js index d0e96481a..7e3e0e0cc 100644 --- a/components/frontend/tailwind.config.js +++ b/components/frontend/tailwind.config.js @@ -66,10 +66,30 @@ module.exports = { from: { height: "var(--radix-accordion-content-height)" }, to: { height: "0" }, }, + "fade-in-up": { + from: { + opacity: "0", + transform: "translateY(20px)", + }, + to: { + opacity: "1", + transform: "translateY(0)", + }, + }, + "fade-in-char": { + from: { + opacity: "0", + }, + to: { + opacity: "1", + }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", + "fade-in-up": "fade-in-up 0.5s ease-out", + "fade-in-char": "fade-in-char 0.15s ease-in", }, }, },