diff --git a/package-lock.json b/package-lock.json index f890c1137..d0fb01d8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "digma-ui", - "version": "15.2.0-alpha.0", + "version": "15.2.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "digma-ui", - "version": "15.2.0-alpha.0", + "version": "15.2.0-alpha.1", "license": "MIT", "dependencies": { "@floating-ui/react": "^0.25.1", "@formkit/auto-animate": "^0.8.2", + "@microsoft/fetch-event-source": "^2.0.1", "@react-oauth/google": "^0.12.1", "@reduxjs/toolkit": "^2.5.0", "@tanstack/react-table": "^8.7.8", @@ -3757,6 +3758,12 @@ "react": ">=16" } }, + "node_modules/@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==", + "license": "MIT" + }, "node_modules/@mjackson/form-data-parser": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@mjackson/form-data-parser/-/form-data-parser-0.4.0.tgz", diff --git a/package.json b/package.json index 1b631815a..141516ff4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "digma-ui", - "version": "15.2.0-alpha.0", + "version": "15.2.0-alpha.1", "description": "Digma UI", "scripts": { "lint:eslint": "eslint --cache .", @@ -120,6 +120,7 @@ "dependencies": { "@floating-ui/react": "^0.25.1", "@formkit/auto-animate": "^0.8.2", + "@microsoft/fetch-event-source": "^2.0.1", "@react-oauth/google": "^0.12.1", "@reduxjs/toolkit": "^2.5.0", "@tanstack/react-table": "^8.7.8", diff --git a/src/components/Agentic/IncidentDetails/Chat/index.tsx b/src/components/Agentic/IncidentDetails/Chat/index.tsx deleted file mode 100644 index f97303472..000000000 --- a/src/components/Agentic/IncidentDetails/Chat/index.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { Fragment, useEffect, useMemo, useState } from "react"; -import { useParams } from "react-router"; -import { useStableSearchParams } from "../../../../hooks/useStableSearchParams"; -import { - useGetIncidentAgentChatEventsQuery, - useSendMessageToIncidentAgentChatMutation -} from "../../../../redux/services/digma"; -import type { IncidentAgentChatEvent } from "../../../../redux/services/types"; -import { isNumber } from "../../../../typeGuards/isNumber"; -import { sendUserActionTrackingEvent } from "../../../../utils/actions/sendUserActionTrackingEvent"; -import { ThreeCirclesSpinner } from "../../../common/ThreeCirclesSpinner"; -import { Spinner } from "../../../common/v3/Spinner"; -import { PromptInput } from "../../common/PromptInput"; -import { trackingEvents } from "../../tracking"; -import { Accordion } from "../AgentEvents/Accordion"; -import { TypingMarkdown } from "../TypingMarkdown"; -import { useAutoScroll } from "../useAutoScroll"; -import { convertToMarkdown } from "../utils/convertToMarkdown"; -import * as s from "./styles"; - -const REFRESH_INTERVAL = 10 * 1000; // in milliseconds -const REFRESH_INTERVAL_DURING_STREAMING = 3 * 1000; // in milliseconds -const TYPING_SPEED = 3; // in milliseconds per character - -export const Chat = () => { - const [inputValue, setInputValue] = useState(""); - const params = useParams(); - const incidentId = params.id; - const [searchParams] = useStableSearchParams(); - const agentId = searchParams.get("agent"); - const { elementRef, handleElementScroll, scrollToBottom } = - useAutoScroll(); - const [initialEventsCount, setInitialEventsCount] = useState(); - const [eventsVisibleCount, setEventsVisibleCount] = useState(); - - const [sendMessage, { isLoading: isMessageSending }] = - useSendMessageToIncidentAgentChatMutation(); - - const { data, isLoading } = useGetIncidentAgentChatEventsQuery( - { - incidentId: incidentId ?? "", - agentId: agentId ?? "" - }, - { - skip: !incidentId || !agentId!, - pollingInterval: isMessageSending - ? REFRESH_INTERVAL_DURING_STREAMING - : REFRESH_INTERVAL - } - ); - - const handleInputSubmit = () => { - sendUserActionTrackingEvent( - trackingEvents.INCIDENT_AGENT_MESSAGE_SUBMITTED, - { - agentName: agentId ?? "" - } - ); - setInputValue(""); - scrollToBottom(); - - void sendMessage({ - incidentId: incidentId ?? "", - agentId: agentId ?? "", - data: { text: inputValue } - }); - }; - - const handleMarkdownTypingComplete = (i: number) => () => { - const events = data ?? []; - const aiEventsIndexes = events.reduce((acc, event, index) => { - if (event.type === "ai") { - acc.push(index); - } - return acc; - }, [] as number[]); - - const nextAiEventIndex = aiEventsIndexes.find((el) => el > i); - - if (isNumber(nextAiEventIndex) && nextAiEventIndex >= 0) { - setEventsVisibleCount(nextAiEventIndex + 1); - } else { - setEventsVisibleCount(events.length); - } - }; - - useEffect(() => { - if (data) { - setInitialEventsCount((prev) => (!isNumber(prev) ? data.length : prev)); - setEventsVisibleCount(data.length); - } - }, [data]); - - const visibleEvents = useMemo( - () => - data && isNumber(eventsVisibleCount) - ? data.slice(0, eventsVisibleCount) - : [], - [data, eventsVisibleCount] - ); - - const shouldShowTypingForEvent = (index: number) => - Boolean(initialEventsCount && index >= initialEventsCount); - - const renderChatEvent = (event: IncidentAgentChatEvent, i: number) => { - switch (event.type) { - case "ai": - return ( - - ); - case "tool": - return ( - } - /> - ); - case "human": - return {event.message}; - - default: - return null; - } - }; - - return ( - - - {!data && isLoading && ( - - - - )} - {visibleEvents?.map((x, i) => ( - {renderChatEvent(x, i)} - ))} - {isMessageSending && } - - - - ); -}; diff --git a/src/components/Agentic/IncidentDetails/IncidentAgentChat/index.tsx b/src/components/Agentic/IncidentDetails/IncidentAgentChat/index.tsx new file mode 100644 index 000000000..9170e905e --- /dev/null +++ b/src/components/Agentic/IncidentDetails/IncidentAgentChat/index.tsx @@ -0,0 +1,52 @@ +import { useParams } from "react-router"; +import { useStableSearchParams } from "../../../../hooks/useStableSearchParams"; +import { + useGetIncidentAgentChatEventsQuery, + useSendMessageToIncidentAgentChatMutation +} from "../../../../redux/services/digma"; +import { AgentChat } from "../../common/AgentChat"; + +const REFRESH_INTERVAL = 10 * 1000; // in milliseconds +const REFRESH_INTERVAL_DURING_STREAMING = 3 * 1000; // in milliseconds + +export const IncidentAgentChat = () => { + const params = useParams(); + const incidentId = params.id; + const [searchParams] = useStableSearchParams(); + const agentId = searchParams.get("agent"); + + const [sendMessage, { isLoading: isMessageSending }] = + useSendMessageToIncidentAgentChatMutation(); + + const handleMessageSend = (text: string) => { + void sendMessage({ + incidentId: incidentId ?? "", + agentId: agentId ?? "", + data: { text } + }); + }; + + const { data, isLoading } = useGetIncidentAgentChatEventsQuery( + { + incidentId: incidentId ?? "", + agentId: agentId ?? "" + }, + { + skip: !incidentId || !agentId, + pollingInterval: isMessageSending + ? REFRESH_INTERVAL_DURING_STREAMING + : REFRESH_INTERVAL + } + ); + + return ( + + ); +}; diff --git a/src/components/Agentic/IncidentDetails/index.tsx b/src/components/Agentic/IncidentDetails/index.tsx index ac95de3a4..4d8957a4d 100644 --- a/src/components/Agentic/IncidentDetails/index.tsx +++ b/src/components/Agentic/IncidentDetails/index.tsx @@ -18,7 +18,7 @@ import { trackingEvents } from "../tracking"; import { AdditionalInfo } from "./AdditionalInfo"; import { AgentEvents } from "./AgentEvents"; import { AgentFlowChart } from "./AgentFlowChart"; -import { Chat } from "./Chat"; +import { IncidentAgentChat } from "./IncidentAgentChat"; import { IncidentMetaData } from "./IncidentMetaData"; import * as s from "./styles"; import type { AgentViewMode } from "./types"; @@ -185,7 +185,7 @@ export const IncidentDetails = () => { {agentId ? ( agentViewMode === "chat" ? ( - + ) : ( ) diff --git a/src/components/Agentic/IncidentTemplate/AddMCPServerDialog/index.tsx b/src/components/Agentic/IncidentTemplate/AddMCPServerDialog/index.tsx index 10e4e1972..30bd5936f 100644 --- a/src/components/Agentic/IncidentTemplate/AddMCPServerDialog/index.tsx +++ b/src/components/Agentic/IncidentTemplate/AddMCPServerDialog/index.tsx @@ -1,9 +1,8 @@ import { useState } from "react"; import { sendUserActionTrackingEvent } from "../../../../utils/actions/sendUserActionTrackingEvent"; -import { CrossIcon } from "../../../common/icons/12px/CrossIcon"; +import { Dialog } from "../../common/Dialog"; import { trackingEvents } from "../../tracking"; import { ServerStep } from "./ServerStep"; -import * as s from "./styles"; import { ToolsStep } from "./ToolsStep"; import type { AddMCPServerDialogProps } from "./types"; @@ -45,24 +44,16 @@ export const AddMCPServerDialog = ({ /> ]; - const handleCloseButtonClick = () => { + const handleDialogClose = () => { sendUserActionTrackingEvent( - trackingEvents.INCIDENT_TEMPLATE_ADD_MCP_DIALOG_CLOSE_BUTTON_CLICKED + trackingEvents.INCIDENT_TEMPLATE_ADD_MCP_DIALOG_CLOSED ); onClose(); }; return ( - - - - Wizard - - - - - + {steps[currentStep]} - + ); }; diff --git a/src/components/Agentic/IncidentsContainer/CreateIncidentChatOverlay/index.tsx b/src/components/Agentic/IncidentsContainer/CreateIncidentChatOverlay/index.tsx new file mode 100644 index 000000000..ab27feba5 --- /dev/null +++ b/src/components/Agentic/IncidentsContainer/CreateIncidentChatOverlay/index.tsx @@ -0,0 +1,209 @@ +import { fetchEventSource } from "@microsoft/fetch-event-source"; +import { useEffect, useRef, useState } from "react"; +import { useAgenticDispatch } from "../../../../containers/Agentic/hooks"; +import { + useGetIncidentAgentEventsQuery, + useSendMessageToIncidentCreationChatMutation +} from "../../../../redux/services/digma"; +import type { IncidentAgentEvent } from "../../../../redux/services/types"; +import { setIsCreateIncidentChatOpen } from "../../../../redux/slices/incidentsSlice"; +import { isString } from "../../../../typeGuards/isString"; +import { sendUserActionTrackingEvent } from "../../../../utils/actions/sendUserActionTrackingEvent"; +import { CancelConfirmation } from "../../../common/CancelConfirmation"; +import { Dialog } from "../../common/Dialog"; +import { trackingEvents } from "../../tracking"; +import * as s from "./styles"; + +const AGENT_ID = "incident_entry"; +const PROMPT_FONT_SIZE = 14; // in pixels +const REFRESH_INTERVAL = 10 * 1000; // in milliseconds +const REFRESH_INTERVAL_DURING_STREAMING = 3 * 1000; // in milliseconds + +export const CreateIncidentChatOverlay = () => { + const [incidentId, setIncidentId] = useState(); + const [ + isCloseConfirmationDialogVisible, + setIsCloseConfirmationDialogVisible + ] = useState(false); + const [isStartMessageSending, setIsStartMessageSending] = useState(false); + const abortControllerRef = useRef(null); + const [accumulatedData, setAccumulatedData] = + useState(); + + const dispatch = useAgenticDispatch(); + + const [sendMessage, { isLoading: isSubsequentMessageSending }] = + useSendMessageToIncidentCreationChatMutation(); + + const isMessageSending = isStartMessageSending || isSubsequentMessageSending; + + const { data, isLoading } = useGetIncidentAgentEventsQuery( + { + incidentId: incidentId ?? "", + agentId: AGENT_ID + }, + { + skip: !incidentId, + pollingInterval: isMessageSending + ? REFRESH_INTERVAL_DURING_STREAMING + : REFRESH_INTERVAL + } + ); + + const handleCreateIncidentChatMessageSend = (text: string) => { + // Send first message to start the incident creation chat + if (!incidentId) { + setAccumulatedData([ + { + type: "human", + agent_name: "incident_entry", + message: text, + tool_name: null, + mcp_name: null + } + ]); + // Stop any existing connection + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + abortControllerRef.current = new AbortController(); + + setIsStartMessageSending(true); + void fetchEventSource( + `${ + isString(window.digmaApiProxyPrefix) + ? window.digmaApiProxyPrefix + : "/api/" + }Agentic/incident-entry`, + { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json" + }, + signal: abortControllerRef.current.signal, + body: JSON.stringify({ + text + }), + onopen: (response: Response) => { + if (response.ok) { + setIncidentId( + response.headers.get("agentic-conversation-id") ?? "" + ); + setIsStartMessageSending(false); + return Promise.resolve(); + } else { + setIsStartMessageSending(false); + return Promise.reject( + new Error(`HTTP ${response.status}: ${response.statusText}`) + ); + } + }, + // onmessage: (message: EventSourceMessage) => { + // if (message.data) { + // try { + // const parsedData = JSON.parse( + // message.data + // ) as IncidentAgentEvent; + // if (["human", "token"].includes(parsedData.type)) { + // setAccumulatedData((prev) => + // prev ? [...prev, parsedData] : [parsedData] + // ); + // } + // if (parsedData.type === "input_user_required") { + // setIsStartMessageSending(false); + // } + // } catch (error) { + // // eslint-disable-next-line no-console + // console.error("Error parsing message data:", error); + // } + // } + // }, + onerror: (err: unknown) => { + abortControllerRef.current = null; + setIsStartMessageSending(false); + if (err instanceof Error) { + // eslint-disable-next-line no-console + console.error("Error starting incident creation chat:", err); + } else { + // eslint-disable-next-line no-console + console.error("Unknown error starting incident creation chat"); + } + } + } + ); + } + + // Send subsequent messages to the incident creation chat + if (incidentId) { + void sendMessage({ + incidentId, + data: { text } + }); + } + }; + + const handleIncidentNavigate = (id: string) => { + dispatch(setIsCreateIncidentChatOpen(false)); + setIncidentId(id); + }; + + const handleCreateIncidentChatDialogClose = () => { + setIsCloseConfirmationDialogVisible(true); + }; + + const handleCloseConfirmationDialogClose = () => { + setIsCloseConfirmationDialogVisible(false); + }; + + const handleCloseConfirmationDialogConfirm = () => { + sendUserActionTrackingEvent( + trackingEvents.INCIDENT_CREATION_CHAT_DIALOG_CLOSED + ); + setIsCloseConfirmationDialogVisible(false); + dispatch(setIsCreateIncidentChatOpen(false)); + }; + + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + return ( + <> + + + 0 ? data : accumulatedData} + isDataLoading={isLoading} + onMessageSend={handleCreateIncidentChatMessageSend} + isMessageSending={isMessageSending} + promptFontSize={PROMPT_FONT_SIZE} + onNavigateToIncident={handleIncidentNavigate} + /> + + + {isCloseConfirmationDialogVisible && ( + + + + )} + + ); +}; diff --git a/src/components/Agentic/IncidentsContainer/CreateIncidentChatOverlay/styles.ts b/src/components/Agentic/IncidentsContainer/CreateIncidentChatOverlay/styles.ts new file mode 100644 index 000000000..342d9dac4 --- /dev/null +++ b/src/components/Agentic/IncidentsContainer/CreateIncidentChatOverlay/styles.ts @@ -0,0 +1,15 @@ +import styled from "styled-components"; +import { Overlay } from "../../../common/Overlay"; +import { AgentChat } from "../../common/AgentChat"; + +export const StyledOverlay = styled(Overlay)` + align-items: center; +`; + +export const StyledAgentChat = styled(AgentChat)` + ${/* TODO: change to color from the theme */ ""} + background: #000; + border-radius: 8px; + padding: 24px; + gap: 12px; +`; diff --git a/src/components/Agentic/IncidentsContainer/CreateIncidentChatOverlay/types.ts b/src/components/Agentic/IncidentsContainer/CreateIncidentChatOverlay/types.ts new file mode 100644 index 000000000..c1c7d30b1 --- /dev/null +++ b/src/components/Agentic/IncidentsContainer/CreateIncidentChatOverlay/types.ts @@ -0,0 +1,10 @@ +export interface OverlayProps { + $transitionDuration: number; + $transitionClassName: string; + $isVisible: boolean; +} + +export interface PopupContainerProps { + $transitionDuration: number; + $transitionClassName: string; +} diff --git a/src/components/Agentic/IncidentsContainer/index.tsx b/src/components/Agentic/IncidentsContainer/index.tsx index 689313a49..365986d1c 100644 --- a/src/components/Agentic/IncidentsContainer/index.tsx +++ b/src/components/Agentic/IncidentsContainer/index.tsx @@ -1,8 +1,17 @@ import { Outlet } from "react-router"; +import { useAgenticSelector } from "../../../containers/Agentic/hooks"; +import { CreateIncidentChatOverlay } from "./CreateIncidentChatOverlay"; import * as s from "./styles"; -export const IncidentsContainer = () => ( - - - -); +export const IncidentsContainer = () => { + const isCreateIncidentChatOpen = useAgenticSelector( + (state) => state.incidents.isCreateIncidentChatOpen + ); + + return ( + + + {isCreateIncidentChatOpen && } + + ); +}; diff --git a/src/components/Agentic/Sidebar/index.tsx b/src/components/Agentic/Sidebar/index.tsx index 0beb72041..41a8ed0b7 100644 --- a/src/components/Agentic/Sidebar/index.tsx +++ b/src/components/Agentic/Sidebar/index.tsx @@ -2,14 +2,19 @@ import { usePostHog } from "posthog-js/react"; import { useEffect, useMemo, useState } from "react"; import { useLocation, useNavigate, useParams } from "react-router"; import { useTheme } from "styled-components"; -import { useAgenticSelector } from "../../../containers/Agentic/hooks"; +import { + useAgenticDispatch, + useAgenticSelector +} from "../../../containers/Agentic/hooks"; import { useLogoutMutation } from "../../../redux/services/auth"; import { useGetIncidentsQuery } from "../../../redux/services/digma"; import type { IncidentResponseItem } from "../../../redux/services/types"; +import { setIsCreateIncidentChatOpen } from "../../../redux/slices/incidentsSlice"; import { sendUserActionTrackingEvent } from "../../../utils/actions/sendUserActionTrackingEvent"; import { getThemeKind } from "../../common/App/styles"; import { LogoutIcon } from "../../common/icons/16px/LogoutIcon"; import { NewPopover } from "../../common/NewPopover"; +import { NewButton } from "../../common/v3/NewButton"; import { Tooltip } from "../../common/v3/Tooltip"; import { MenuList } from "../../Navigation/common/MenuList"; import { Popup } from "../../Navigation/common/Popup"; @@ -31,6 +36,7 @@ export const Sidebar = () => { const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const posthog = usePostHog(); const location = useLocation(); + const dispatch = useAgenticDispatch(); const { data } = useGetIncidentsQuery(undefined, { pollingInterval: REFRESH_INTERVAL @@ -49,6 +55,11 @@ export const Sidebar = () => { void navigate(`/incidents/${id}`); }; + const handleCreateButtonClick = () => { + sendUserActionTrackingEvent(trackingEvents.SIDEBAR_CREATE_BUTTON_CLICKED); + dispatch(setIsCreateIncidentChatOpen(true)); + }; + const handleTemplateButtonClick = () => { sendUserActionTrackingEvent(trackingEvents.SIDEBAR_TEMPLATE_BUTTON_CLICKED); void navigate("/incidents/template"); @@ -111,7 +122,10 @@ export const Sidebar = () => { /> - Incidents + + Incidents + + {sortedIncidents.map((incident) => ( theme.colors.v3.text.primary}; diff --git a/src/components/Agentic/common/AgentChat/index.tsx b/src/components/Agentic/common/AgentChat/index.tsx new file mode 100644 index 000000000..b623b2e82 --- /dev/null +++ b/src/components/Agentic/common/AgentChat/index.tsx @@ -0,0 +1,143 @@ +import { Fragment, useEffect, useMemo, useState } from "react"; +import type { IncidentAgentEvent } from "../../../../redux/services/types"; +import { isNumber } from "../../../../typeGuards/isNumber"; +import { sendUserActionTrackingEvent } from "../../../../utils/actions/sendUserActionTrackingEvent"; +import { Chat } from "../../common/Chat"; +import { Accordion } from "../../IncidentDetails/AgentEvents/Accordion"; +import { TypingMarkdown } from "../../IncidentDetails/TypingMarkdown"; +import { convertToMarkdown } from "../../IncidentDetails/utils/convertToMarkdown"; +import { trackingEvents } from "../../tracking"; +import * as s from "./styles"; +import type { AgentChatProps } from "./types"; + +const TYPING_SPEED = 3; // in milliseconds per character + +export const AgentChat = ({ + incidentId, + agentId, + onMessageSend, + isMessageSending, + className, + data, + isDataLoading, + onNavigateToIncident +}: AgentChatProps) => { + const [initialEventsCount, setInitialEventsCount] = useState(); + const [eventsVisibleCount, setEventsVisibleCount] = useState(); + + const handleMessageSend = (text: string) => { + sendUserActionTrackingEvent( + trackingEvents.INCIDENT_AGENT_MESSAGE_SUBMITTED, + { + agentName: agentId ?? "" + } + ); + + onMessageSend(text); + }; + + const handleViewIncidentLinkClick = () => { + sendUserActionTrackingEvent(trackingEvents.VIEW_NEW_INCIDENT_LINK_CLICKED); + + if (incidentId) { + onNavigateToIncident?.(incidentId); + } + }; + + const handleMarkdownTypingComplete = (i: number) => () => { + const events = data ?? []; + const aiEventsIndexes = events.reduce((acc, event, index) => { + if (event.type === "ai") { + acc.push(index); + } + return acc; + }, [] as number[]); + + const nextAiEventIndex = aiEventsIndexes.find((el) => el > i); + + if (isNumber(nextAiEventIndex) && nextAiEventIndex >= 0) { + setEventsVisibleCount(nextAiEventIndex + 1); + } else { + setEventsVisibleCount(events.length); + } + }; + + useEffect(() => { + if (data) { + setInitialEventsCount((prev) => (!isNumber(prev) ? data.length : prev)); + setEventsVisibleCount(data.length); + } + }, [data]); + + const visibleEvents = useMemo( + () => + data && isNumber(eventsVisibleCount) + ? data.slice(0, eventsVisibleCount) + : [], + [data, eventsVisibleCount] + ); + + const shouldShowTypingForEvent = (index: number) => + Boolean(initialEventsCount && index >= initialEventsCount); + + const renderChatEvent = (event: IncidentAgentEvent, i: number) => { + switch (event.type) { + case "ai": + case "token": + return ( + + ); + case "tool": + return ( + } + /> + ); + case "human": + return {event.message}; + case "agent_end": { + if (event.agent_name === "incident_entry") { + return ( + + New incident has been created.{" "} + + View + + + ); + } + break; + } + default: + return null; + } + }; + + return ( + + {visibleEvents?.map((x, i) => ( + {renderChatEvent(x, i)} + ))} + + } + /> + ); +}; diff --git a/src/components/Agentic/common/AgentChat/styles.ts b/src/components/Agentic/common/AgentChat/styles.ts new file mode 100644 index 000000000..182935d40 --- /dev/null +++ b/src/components/Agentic/common/AgentChat/styles.ts @@ -0,0 +1,19 @@ +import styled from "styled-components"; +import { subheading1RegularTypography } from "../../../common/App/typographies"; +import { Link } from "../../../common/Link"; + +export const HumanMessage = styled.div` + background: ${({ theme }) => theme.colors.v3.surface.highlight}; + padding: 8px; + border-radius: 8px; + align-self: flex-end; +`; + +export const AgentMessage = styled.div` + ${subheading1RegularTypography} + color: ${({ theme }) => theme.colors.v3.text.secondary}; +`; + +export const StyledLink = styled(Link)` + font-size: inherit; +`; diff --git a/src/components/Agentic/common/AgentChat/types.ts b/src/components/Agentic/common/AgentChat/types.ts new file mode 100644 index 000000000..8e8aff202 --- /dev/null +++ b/src/components/Agentic/common/AgentChat/types.ts @@ -0,0 +1,13 @@ +import type { IncidentAgentEvent } from "../../../../redux/services/types"; + +export interface AgentChatProps { + incidentId?: string; + agentId?: string; + onMessageSend: (text: string) => void; + isMessageSending: boolean; + className?: string; + promptFontSize?: number; + data?: IncidentAgentEvent[]; + isDataLoading: boolean; + onNavigateToIncident?: (incidentId: string) => void; +} diff --git a/src/components/Agentic/common/Chat/index.tsx b/src/components/Agentic/common/Chat/index.tsx new file mode 100644 index 000000000..406b13d87 --- /dev/null +++ b/src/components/Agentic/common/Chat/index.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; +import { ThreeCirclesSpinner } from "../../../common/ThreeCirclesSpinner"; +import { Spinner } from "../../../common/v3/Spinner"; +import { useAutoScroll } from "../../IncidentDetails/useAutoScroll"; +import { PromptInput } from "../PromptInput"; +import * as s from "./styles"; +import type { ChatProps } from "./types"; + +export const Chat = ({ + isInitialLoading, + isMessageSending, + onMessageSend, + chatContent, + className, + promptFontSize +}: ChatProps) => { + const [inputValue, setInputValue] = useState(""); + const { elementRef, handleElementScroll, scrollToBottom } = + useAutoScroll(); + + const handleInputSubmit = () => { + setInputValue(""); + scrollToBottom(); + onMessageSend(inputValue); + }; + + return ( + + + {isInitialLoading && ( + + + + )} + {chatContent} + {isMessageSending && } + + + + ); +}; diff --git a/src/components/Agentic/IncidentDetails/Chat/styles.ts b/src/components/Agentic/common/Chat/styles.ts similarity index 73% rename from src/components/Agentic/IncidentDetails/Chat/styles.ts rename to src/components/Agentic/common/Chat/styles.ts index 4c00eb705..4bfa2103d 100644 --- a/src/components/Agentic/IncidentDetails/Chat/styles.ts +++ b/src/components/Agentic/common/Chat/styles.ts @@ -18,13 +18,6 @@ export const ChatHistory = styled.div` padding: 8px; `; -export const HumanMessage = styled.div` - background: ${({ theme }) => theme.colors.v3.surface.highlight}; - padding: 8px; - border-radius: 8px; - align-self: flex-end; -`; - export const LoadingContainer = styled.div` display: flex; justify-content: center; diff --git a/src/components/Agentic/common/Chat/types.ts b/src/components/Agentic/common/Chat/types.ts new file mode 100644 index 000000000..b1550ec4d --- /dev/null +++ b/src/components/Agentic/common/Chat/types.ts @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; + +export interface ChatProps { + isInitialLoading: boolean; + isMessageSending: boolean; + onMessageSend: (text: string) => void; + chatContent: ReactNode; + className?: string; + promptFontSize?: number; // in pixels +} diff --git a/src/components/Agentic/common/Dialog/index.tsx b/src/components/Agentic/common/Dialog/index.tsx new file mode 100644 index 000000000..2669b5383 --- /dev/null +++ b/src/components/Agentic/common/Dialog/index.tsx @@ -0,0 +1,18 @@ +import { isString } from "../../../../typeGuards/isString"; +import { CrossIcon } from "../../../common/icons/12px/CrossIcon"; +import * as s from "./styles"; +import type { DialogProps } from "./types"; + +export const Dialog = ({ title, onClose, children }: DialogProps) => ( + + + + {isString(title) ? title : null} + + + + + + {children} + +); diff --git a/src/components/Agentic/common/Dialog/styles.ts b/src/components/Agentic/common/Dialog/styles.ts new file mode 100644 index 000000000..f0ea0331e --- /dev/null +++ b/src/components/Agentic/common/Dialog/styles.ts @@ -0,0 +1,34 @@ +import styled from "styled-components"; + +export const Container = styled.div` + display: flex; + flex-direction: column; + width: 635px; + height: 420px; + padding: 12px; + gap: 16px; + border-radius: 4px; + background: ${({ theme }) => theme.colors.v3.surface.primary}; +`; + +export const Header = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + color: ${({ theme }) => theme.colors.v3.text.primary}; + ${/* TODO: change to typography from the theme*/ ""} + font-size: 14px; + font-weight: 600; + width: 100%; +`; + +export const CloseButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + color: ${({ theme }) => theme.colors.v3.text.secondary}; + background: none; + border: none; + cursor: pointer; + margin-left: auto; +`; diff --git a/src/components/Agentic/common/Dialog/types.ts b/src/components/Agentic/common/Dialog/types.ts new file mode 100644 index 000000000..9ff98e2aa --- /dev/null +++ b/src/components/Agentic/common/Dialog/types.ts @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; + +export interface DialogProps { + title?: string; + onClose: () => void; + children: ReactNode; +} diff --git a/src/components/Agentic/common/PromptInput/index.tsx b/src/components/Agentic/common/PromptInput/index.tsx index 6f8a8ba57..543ce6dd3 100644 --- a/src/components/Agentic/common/PromptInput/index.tsx +++ b/src/components/Agentic/common/PromptInput/index.tsx @@ -17,7 +17,8 @@ export const PromptInput = ({ isSubmitting, className, placeholder, - isDisabled + isDisabled, + fontSize = s.TEXT_AREA_DEFAULT_FONT_SIZE }: PromptInputProps) => { const isSubmittingDisabled = isSubmitting ?? value.trim() === ""; const formRef = useRef(null); @@ -68,7 +69,7 @@ export const PromptInput = ({ if (textArea) { const MAX_LINES = 3; const linesCount = value.split("\n").length; - const lineHeight = s.TEXT_AREA_FONT_SIZE * s.TEXT_AREA_LINE_HEIGHT; + const lineHeight = fontSize * s.TEXT_AREA_LINE_HEIGHT; const newLinesHeight = Math.min( lineHeight * linesCount, lineHeight * MAX_LINES @@ -76,7 +77,7 @@ export const PromptInput = ({ setTextAreaHeight(Math.max(newLinesHeight, s.TEXT_AREA_MIN_HEIGHT)); } - }, [value]); + }, [value, fontSize]); const formHeight = textAreaHeight + s.FORM_TOP_BOTTOM_PADDING * 2; @@ -89,6 +90,7 @@ export const PromptInput = ({ > ` @@ -21,7 +21,7 @@ export const Form = styled.form` export const TextArea = styled.textarea` color: ${({ theme }) => theme.colors.v3.text.tertiary}; - font-size: ${TEXT_AREA_FONT_SIZE}px; + font-size: ${({ $fontSize }) => $fontSize}px; background: none; border: none; outline: none; diff --git a/src/components/Agentic/common/PromptInput/types.ts b/src/components/Agentic/common/PromptInput/types.ts index 2f36b2c09..60bfacc9b 100644 --- a/src/components/Agentic/common/PromptInput/types.ts +++ b/src/components/Agentic/common/PromptInput/types.ts @@ -6,6 +6,7 @@ export interface PromptInputProps { className?: string; placeholder?: string; isDisabled?: boolean; + fontSize?: number; // in pixels } export interface FormProps { @@ -14,4 +15,5 @@ export interface FormProps { export interface TextAreaProps { $height?: number; + $fontSize?: number; } diff --git a/src/components/Agentic/tracking.ts b/src/components/Agentic/tracking.ts index da069db65..51ced1caf 100644 --- a/src/components/Agentic/tracking.ts +++ b/src/components/Agentic/tracking.ts @@ -8,7 +8,11 @@ export const trackingEvents = addPrefix( SIDEBAR_USER_MENU_OPEN_CHANGED: "sidebar user menu open changed", SIDEBAR_USER_MENU_ITEM_CLICKED: "sidebar user menu item clicked", SIDEBAR_INCIDENTS_LIST_ITEM_CLICKED: "sidebar incidents list item clicked", + SIDEBAR_CREATE_BUTTON_CLICKED: "sidebar create button clicked", SIDEBAR_TEMPLATE_BUTTON_CLICKED: "sidebar template button clicked", + INCIDENT_CREATION_CHAT_DIALOG_CLOSED: + "incident creation chat dialog closed", + VIEW_NEW_INCIDENT_LINK_CLICKED: "view incident link clicked", FLOW_CHART_NODE_CLICKED: "flow chart node clicked", FLOW_CHART_NODE_KEBAB_MENU_CLICKED: "flow chart node kebab menu clicked", FLOW_CHART_NODE_KEBAB_MENU_ITEM_CLICKED: @@ -23,8 +27,8 @@ export const trackingEvents = addPrefix( "incident template add mcp dialog connect button clicked", INCIDENT_TEMPLATE_ADD_MCP_DIALOG_CANCEL_BUTTON_CLICKED: "incident template add mcp dialog cancel button clicked", - INCIDENT_TEMPLATE_ADD_MCP_DIALOG_CLOSE_BUTTON_CLICKED: - "incident template add mcp dialog close button clicked", + INCIDENT_TEMPLATE_ADD_MCP_DIALOG_CLOSED: + "incident template add mcp dialog closed", INCIDENT_TEMPLATE_EDIT_MCP_DIALOG_OPENED: "incident template edit mcp dialog opened", INCIDENT_TEMPLATE_EDIT_MCP_DIALOG_SAVE_BUTTON_CLICKED: diff --git a/src/redux/services/digma.ts b/src/redux/services/digma.ts index 2d6537945..efc340179 100644 --- a/src/redux/services/digma.ts +++ b/src/redux/services/digma.ts @@ -86,7 +86,8 @@ import type { PinErrorPayload, RecheckInsightPayload, ResendConfirmationEmailPayload, - sendMessageToIncidentAgentChatPayload, + SendMessageToIncidentAgentChatPayload, + SendMessageToIncidentCreationChatPayload, SetEndpointsIssuesPayload, SetMetricsReportDataPayload, SetServiceEndpointsPayload, @@ -104,7 +105,8 @@ export const digmaApi = createApi({ "Error", "Insight", "RecentActivity", - "IncidentAgentChatEvent" + "IncidentAgentChatEvent", + "IncidentEntryAgentChatEvent" ], reducerPath: "digmaApi", baseQuery: fetchBaseQuery({ @@ -380,7 +382,7 @@ export const digmaApi = createApi({ url: "Insights/get_insights_view", params: data }), - transformResponse: (response: GetInsightsResponse, meta, arg) => ({ + transformResponse: (response: GetInsightsResponse, _, arg) => ({ data: response, extra: arg.extra }), @@ -394,7 +396,7 @@ export const digmaApi = createApi({ url: "Insights/statistics", params: data }), - transformResponse: (response: GetInsightsStatsResponse, meta, arg) => ({ + transformResponse: (response: GetInsightsStatsResponse, _, arg) => ({ data: response, extra: { spanCodeObjectId: arg.scopedSpanCodeObjectId @@ -536,11 +538,7 @@ export const digmaApi = createApi({ url: "Spans/environments", params: data }), - transformResponse: ( - response: GetSpanEnvironmentsResponse, - meta, - arg - ) => ({ + transformResponse: (response: GetSpanEnvironmentsResponse, _, arg) => ({ data: response, extra: { spanCodeObjectId: arg.spanCodeObjectId @@ -582,7 +580,8 @@ export const digmaApi = createApi({ url: `Agentic/incidents/${window.encodeURIComponent( incidentId )}/agents/${window.encodeURIComponent(agentId)}/events` - }) + }), + providesTags: ["IncidentAgentChatEvent", "IncidentEntryAgentChatEvent"] }), getIncidentAgentChatEvents: builder.query< GetIncidentAgentChatEventsResponse, @@ -596,8 +595,8 @@ export const digmaApi = createApi({ providesTags: ["IncidentAgentChatEvent"] }), sendMessageToIncidentAgentChat: builder.mutation< - void, // text/event-stream - sendMessageToIncidentAgentChatPayload + unknown, // text/event-stream + SendMessageToIncidentAgentChatPayload >({ query: ({ incidentId, agentId, data }) => ({ url: `Agentic/incidents/${window.encodeURIComponent( @@ -607,6 +606,17 @@ export const digmaApi = createApi({ body: data }), invalidatesTags: ["IncidentAgentChatEvent"] + }), + sendMessageToIncidentCreationChat: builder.mutation< + unknown, // text/event-stream + SendMessageToIncidentCreationChatPayload + >({ + query: ({ incidentId, data }) => ({ + url: `Agentic/incident-entry/${window.encodeURIComponent(incidentId)}`, + method: "POST", + body: data + }), + invalidatesTags: ["IncidentEntryAgentChatEvent"] }) }) }); @@ -666,5 +676,6 @@ export const { useGetIncidentAgentsQuery, useGetIncidentAgentEventsQuery, useGetIncidentAgentChatEventsQuery, - useSendMessageToIncidentAgentChatMutation + useSendMessageToIncidentAgentChatMutation, + useSendMessageToIncidentCreationChatMutation } = digmaApi; diff --git a/src/redux/services/types.ts b/src/redux/services/types.ts index 5151c6ebe..103a7a046 100644 --- a/src/redux/services/types.ts +++ b/src/redux/services/types.ts @@ -1174,42 +1174,37 @@ export interface GetIncidentAgentEventsPayload { agentId: string; } -export interface IncidentAgentEventToken { - type: "token"; - agent_name: string; +export interface IncidentAgentEvent { + type: + | "token" + | "ai" + | "human" + | "tool" + | "error" + | "agent_end" + | "input_user_required"; message: string; -} - -export interface IncidentAgentEventTool { - type: "tool"; agent_name: string; - message: string; - mcp_name: string; - tool_name: string; + tool_name?: string | null; + mcp_name?: string | null; } -export type GetIncidentAgentEventsResponse = ( - | IncidentAgentEventToken - | IncidentAgentEventTool -)[]; +export type GetIncidentAgentEventsResponse = IncidentAgentEvent[]; export interface GetIncidentAgentChatEventsPayload { incidentId: string; agentId: string; } -export interface IncidentAgentChatEvent { - type: "ai" | "human" | "tool"; - message: string; - agent_name: string; - tool_name: string | null; - mcp_name: string | null; -} - -export type GetIncidentAgentChatEventsResponse = IncidentAgentChatEvent[]; +export type GetIncidentAgentChatEventsResponse = IncidentAgentEvent[]; -export interface sendMessageToIncidentAgentChatPayload { +export interface SendMessageToIncidentAgentChatPayload { incidentId: string; agentId: string; data: { text: string }; } + +export interface SendMessageToIncidentCreationChatPayload { + incidentId: string; + data: { text: string }; +} diff --git a/src/redux/slices/incidentsSlice.ts b/src/redux/slices/incidentsSlice.ts index c45311967..1980bffa7 100644 --- a/src/redux/slices/incidentsSlice.ts +++ b/src/redux/slices/incidentsSlice.ts @@ -3,17 +3,22 @@ import { globalClear } from "../actions"; import { STATE_VERSION } from "../constants"; import type { BaseState } from "./types"; -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface IncidentsState extends BaseState {} +export interface IncidentsState extends BaseState { + isCreateIncidentChatOpen: boolean; +} const initialState: IncidentsState = { - version: STATE_VERSION + version: STATE_VERSION, + isCreateIncidentChatOpen: false }; export const incidentsSlice = createSlice({ name: "incidents", initialState, reducers: { + setIsCreateIncidentChatOpen: (state, action: { payload: boolean }) => { + state.isCreateIncidentChatOpen = action.payload; + }, clear: () => initialState }, extraReducers: (builder) => { @@ -21,6 +26,6 @@ export const incidentsSlice = createSlice({ } }); -export const { clear } = incidentsSlice.actions; +export const { setIsCreateIncidentChatOpen, clear } = incidentsSlice.actions; export default incidentsSlice.reducer;