diff --git a/src/components/flow/ai-pipeline-dialog.tsx b/src/components/flow/ai-pipeline-dialog.tsx index 3172715..252c5bc 100644 --- a/src/components/flow/ai-pipeline-dialog.tsx +++ b/src/components/flow/ai-pipeline-dialog.tsx @@ -4,7 +4,6 @@ import { useState, useRef, useCallback, useMemo, useEffect } from "react"; import { Loader2, RotateCcw, Sparkles, AlertTriangle, MessageSquarePlus } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Dialog, @@ -42,6 +41,8 @@ export function AiPipelineDialog({ // --- Generate tab state (unchanged from original) --- const [genPrompt, setGenPrompt] = useState(""); + const genTextareaRef = useRef(null); + const reviewTextareaRef = useRef(null); const [genResult, setGenResult] = useState(""); const [genIsStreaming, setGenIsStreaming] = useState(false); const [genError, setGenError] = useState(null); @@ -71,6 +72,24 @@ export function AiPipelineDialog({ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [conversation.messages, conversation.streamingContent]); + // Auto-grow for generate textarea + useEffect(() => { + const ta = genTextareaRef.current; + if (!ta) return; + ta.style.height = "auto"; + const maxHeight = 4 * 24; // 4 lines + ta.style.height = `${Math.min(ta.scrollHeight, maxHeight)}px`; + }, [genPrompt]); + + // Auto-grow for review textarea + useEffect(() => { + const ta = reviewTextareaRef.current; + if (!ta) return; + ta.style.height = "auto"; + const maxHeight = 4 * 24; + ta.style.height = `${Math.min(ta.scrollHeight, maxHeight)}px`; + }, [reviewPrompt]); + // Compute suggestion statuses across all messages const suggestionStatuses = useMemo(() => { const statuses = new Map(); @@ -84,6 +103,26 @@ export function AiPipelineDialog({ statuses.set(id, status); } + // Additional validation for modify_vrl: configPath must point to a string + for (const s of msg.suggestions) { + if (s.type === "modify_vrl" && statuses.get(s.id) === "actionable") { + const node = nodes.find((n) => (n.data as Record).componentKey === s.componentKey); + if (node) { + const config = (node.data as Record).config as Record; + let value: unknown = config; + for (const part of s.configPath.split(".")) { + if (value == null || typeof value !== "object") { value = undefined; break; } + value = (value as Record)[part]; + } + if (typeof value !== "string") { + statuses.set(s.id, "invalid"); + } else if (!value.includes(s.targetCode)) { + statuses.set(s.id, "outdated"); + } + } + } + } + // Check for outdated suggestions const outdated = detectOutdatedSuggestions( msg.suggestions, @@ -223,6 +262,13 @@ export function AiPipelineDialog({ genAbortRef.current?.abort(); }; + const handleGenKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleGenSubmit(); + } + }; + // --- Review tab handlers --- const handleReviewSubmit = useCallback( @@ -236,6 +282,13 @@ export function AiPipelineDialog({ [reviewPrompt, conversation], ); + const handleReviewKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleReviewSubmit(); + } + }; + const handleApplySelected = useCallback( (messageId: string, suggestions: AiSuggestion[]) => { const { applied, errors } = applySuggestions(suggestions); @@ -258,7 +311,7 @@ export function AiPipelineDialog({ return ( - + @@ -269,7 +322,7 @@ export function AiPipelineDialog({ - setMode(v as "generate" | "review")} className="flex flex-col flex-1 overflow-hidden"> + setMode(v as "generate" | "review")} className="flex flex-col flex-1 min-h-0 overflow-hidden"> Generate @@ -282,12 +335,16 @@ export function AiPipelineDialog({
- setGenPrompt(e.target.value)} + onKeyDown={handleGenKeyDown} placeholder="Collect K8s logs, drop debug, send to Datadog and S3" disabled={genIsStreaming} + rows={1} + className="flex-1 resize-none rounded-md border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50" /> {genIsStreaming ? (
)} + {suggestion.type === "modify_vrl" && ( +
+
+ {suggestion.targetCode} +
+
+ {suggestion.code} +
+
+ )} + {hasConflict && conflictReason && (
@@ -131,6 +143,9 @@ function renderDescription(suggestion: AiSuggestion): React.ReactNode { componentKeys.push(e.from, e.to); } } + if (suggestion.type === "modify_vrl") { + componentKeys.push(suggestion.componentKey); + } if (componentKeys.length === 0) return desc; diff --git a/src/components/vrl-editor/vrl-editor.tsx b/src/components/vrl-editor/vrl-editor.tsx index 72608cb..35af342 100644 --- a/src/components/vrl-editor/vrl-editor.tsx +++ b/src/components/vrl-editor/vrl-editor.tsx @@ -28,7 +28,7 @@ import { VrlSnippetDrawer } from "@/components/flow/vrl-snippet-drawer"; import { VrlFieldsPanel } from "./vrl-fields-panel"; import { VrlAiPanel } from "./vrl-ai-panel"; import { useVrlAiConversation } from "@/hooks/use-vrl-ai-conversation"; -import { cn } from "@/lib/utils"; + import { useTeamStore } from "@/stores/team-store"; import { getMergedOutputSchemas, getSourceOutputSchema } from "@/lib/vector/source-output-schemas"; import type { Monaco, OnMount } from "@monaco-editor/react"; @@ -69,7 +69,8 @@ export function VrlEditor({ value, onChange, sourceTypes, pipelineId, componentK const [sampleInput, setSampleInput] = useState(""); const [testOutput, setTestOutput] = useState(null); const [testError, setTestError] = useState(null); - const [toolsPanel, setToolsPanel] = useState<"fields" | "snippets" | null>(null); + type RightPanel = "fields" | "snippets" | "ai" | null; + const [rightPanel, setRightPanel] = useState("fields"); const [expanded, setExpanded] = useState(false); const editorRef = useRef(null); const monacoRef = useRef(null); @@ -91,10 +92,12 @@ export function VrlEditor({ value, onChange, sourceTypes, pipelineId, componentK ); const aiEnabled = teamQuery.data?.aiEnabled ?? false; - const [aiPanelOpen, setAiPanelOpen] = useState(false); - const canUseAiChat = aiEnabled && !!pipelineId && !!componentKey; + const togglePanel = (panel: RightPanel) => { + setRightPanel((prev) => (prev === panel ? null : panel)); + }; + const isRawTextSource = useMemo(() => { if (!sourceTypes || sourceTypes.length === 0) return false; return sourceTypes.some((t) => { @@ -183,7 +186,7 @@ export function VrlEditor({ value, onChange, sourceTypes, pipelineId, componentK const schema = (sample.schema as Array<{ path: string; type: string; sample: string }>) ?? []; setLiveSchemaFields(schema); if (schema.length > 0) { - setToolsPanel("fields"); + setRightPanel("fields"); } } } else if (data.status === "ERROR" || data.status === "EXPIRED") { @@ -360,17 +363,73 @@ export function VrlEditor({ value, onChange, sourceTypes, pipelineId, componentK {/* Full-screen modal: editor (left) + tools (right) */} e.stopPropagation()} > VRL Editor + + {/* Toolbar — always visible */} +
+ {hasFields && ( + + )} + + {canUseAiChat && ( + + )} + {pipelineId && upstreamSourceKeys && upstreamSourceKeys.length > 0 && ( + <> + + + {sampleEvents.length > 0 && ( + + {sampleEvents.length} sample{sampleEvents.length !== 1 ? "s" : ""} + + )} + + )} +
+
{/* Left: Monaco editor at full height */}
@@ -393,190 +452,125 @@ export function VrlEditor({ value, onChange, sourceTypes, pipelineId, componentK />
- {/* Right: tools pane */} -
- {/* Action buttons */} -
- {hasFields && ( - - )} - - {canUseAiChat && ( - - )} - {pipelineId && upstreamSourceKeys && upstreamSourceKeys.length > 0 && ( - <> - - - {sampleEvents.length > 0 && ( - - {sampleEvents.length} sample{sampleEvents.length !== 1 ? "s" : ""} - + {/* Right panel: single slot for tools OR AI */} + {rightPanel && ( +
+ {/* Tools content (fields, snippets, test panel) */} + {(rightPanel === "fields" || rightPanel === "snippets") && ( +
+ {/* Raw text source hint */} + {isRawTextSource && rightPanel !== "snippets" && ( +
+

This source emits raw text in .message

+

+ Use a parsing function to extract fields — click Snippets → Parsing for examples. +

+
)} - - )} -
- {/* Raw text source hint */} - {isRawTextSource && toolsPanel !== "snippets" && ( -
-

This source emits raw text in .message

-

- Use a parsing function to extract fields — click Snippets → Parsing for examples. -

-
- )} - - {/* Fields panel */} - {toolsPanel === "fields" && ( - - )} - - {/* Snippet drawer */} - {toolsPanel === "snippets" && ( - - )} - - {/* Test panel (always visible) */} -
-
-
- - {sampleEvents.length > 1 && ( -
- - - {sampleIndex + 1}/{sampleEvents.length} - - -
+ {rightPanel === "fields" && ( + )} -
-