From a3b18b62f004952c0c6c736b483e0da7d0ded137 Mon Sep 17 00:00:00 2001 From: Francesco Berardi Date: Fri, 12 Jun 2026 19:17:31 +0200 Subject: [PATCH 1/2] feat(chatbot): add context-aware quick-action buttons to the chat panel --- frontend/src/components/chat/ChatPanel.jsx | 8 ++ frontend/src/components/chat/QuickActions.jsx | 98 +++++++++++++ .../components/chat/QuickActions.test.jsx | 130 ++++++++++++++++++ tests/api_app/chatbot_manager/test_context.py | 9 +- 4 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/chat/QuickActions.jsx create mode 100644 frontend/tests/components/chat/QuickActions.test.jsx diff --git a/frontend/src/components/chat/ChatPanel.jsx b/frontend/src/components/chat/ChatPanel.jsx index 459e515e1f..f24d5d5655 100644 --- a/frontend/src/components/chat/ChatPanel.jsx +++ b/frontend/src/components/chat/ChatPanel.jsx @@ -16,6 +16,7 @@ import { useChatWebSocket } from "./useChatWebSocket"; import { ChatMessageList } from "./ChatMessageList"; import { ChatComposer } from "./ChatComposer"; import { ChatSessionList } from "./ChatSessionList"; +import { QuickActions } from "./QuickActions"; // Connection-state badge shown in the drawer header. const CONNECTION_BADGE = { @@ -45,10 +46,16 @@ export function ChatPanel() { const isOpen = useChatStore((state) => state.isOpen); const close = useChatStore((state) => state.close); const connectionState = useChatStore((state) => state.connectionState); + const isStreaming = useChatStore((state) => state.isStreaming); const assistantUnavailable = useChatStore( (state) => state.assistantUnavailable, ); const error = useChatStore((state) => state.error); + + // Disable the composer and quick actions while a turn is streaming or the socket is not + // connected. ChatComposer computes this internally; QuickActions receives it as a prop. + const inputDisabled = + isStreaming || connectionState !== ConnectionState.CONNECTED; const checkHealth = useChatStore((state) => state.checkHealth); const { sendMessage } = useChatWebSocket(); @@ -129,6 +136,7 @@ export function ChatPanel() { ) : ( <> + )} diff --git a/frontend/src/components/chat/QuickActions.jsx b/frontend/src/components/chat/QuickActions.jsx new file mode 100644 index 0000000000..ba94388b94 --- /dev/null +++ b/frontend/src/components/chat/QuickActions.jsx @@ -0,0 +1,98 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { Button } from "reactstrap"; + +// URL patterns mirror derive_page_context in api_app/chatbot_manager/agent/context.py. +// When the frontend Routes.jsx paths change (/jobs/:id or /investigation/:id), these +// regexes must be updated in lockstep with context.py. Both sides have coupling tests: +// - backend: tests/api_app/chatbot_manager/test_context.py +// - frontend: tests/components/chat/QuickActions.test.jsx +export const JOB_DETAIL_RE = /^\/jobs\/(\d+)(?:\/|$)/; +export const INVESTIGATION_DETAIL_RE = /^\/investigation\/(\d+)(?:\/|$)/; + +const JOB_ACTIONS = [ + { label: "Summarize this job", message: "Summarize job #{id}" }, + { label: "What analyzers ran?", message: "What analyzers ran on job #{id}?" }, + { label: "Show job details", message: "Show me the details of job #{id}" }, +]; + +const INVESTIGATION_ACTIONS = [ + { + label: "Summarize this investigation", + message: "Summarize investigation #{id}", + }, + { + label: "Show investigation tree", + message: "Show the tree for investigation #{id}", + }, +]; + +const GENERIC_ACTIONS = [ + { label: "Show my recent jobs", message: "Show my recent jobs" }, + { + label: "List my investigations", + message: "List my investigations", + }, +]; + +/** + * Derive entity info from the current page URL, mirroring derive_page_context in the + * backend. Returns { type, id } for a recognised entity detail page, or null for any + * other page (dashboard, plugins, history, etc.). + */ +function deriveEntity() { + const { pathname } = window.location; + const jobMatch = pathname.match(JOB_DETAIL_RE); + if (jobMatch) return { type: "job", id: jobMatch[1] }; + const invMatch = pathname.match(INVESTIGATION_DETAIL_RE); + if (invMatch) return { type: "investigation", id: invMatch[1] }; + return null; +} + +/** + * Context-aware quick-action chips. On job and investigation detail pages the user + * gets entity-specific suggestions; everywhere else they see generic "show me" actions. + * Each chip auto-sends its message on click (populate + send, no second step). + */ +export function QuickActions({ onSend, disabled }) { + const entity = deriveEntity(); + let actions; + + if (!entity) { + actions = GENERIC_ACTIONS; + } else if (entity.type === "job") { + actions = JOB_ACTIONS; + } else { + actions = INVESTIGATION_ACTIONS; + } + + const handleClick = (message) => { + const resolved = entity ? message.replace("{id}", entity.id) : message; + onSend(resolved); + }; + + return ( +
+ {actions.map((action) => ( + + ))} +
+ ); +} + +QuickActions.propTypes = { + onSend: PropTypes.func.isRequired, + disabled: PropTypes.bool, +}; + +QuickActions.defaultProps = { + disabled: false, +}; diff --git a/frontend/tests/components/chat/QuickActions.test.jsx b/frontend/tests/components/chat/QuickActions.test.jsx new file mode 100644 index 0000000000..7f4980460f --- /dev/null +++ b/frontend/tests/components/chat/QuickActions.test.jsx @@ -0,0 +1,130 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; + +import { + JOB_DETAIL_RE, + INVESTIGATION_DETAIL_RE, + QuickActions, +} from "../../../src/components/chat/QuickActions"; + +// window.location is non-writable in JSDOM — delete and replace to mock pathname. +function mockLocation(pathname) { + delete window.location; + window.location = new URL(`https://intelowl.test${pathname}`); +} + +function restoreLocation() { + delete window.location; + window.location = new URL("https://intelowl.test/dashboard"); +} + +describe("QuickActions", () => { + afterEach(() => { + restoreLocation(); + }); + + it("shows job-specific chips on a job detail page", () => { + mockLocation("/jobs/42"); + render(); + + expect(screen.getByText("Summarize this job")).toBeInTheDocument(); + expect(screen.getByText("What analyzers ran?")).toBeInTheDocument(); + expect(screen.getByText("Show job details")).toBeInTheDocument(); + // generic chips must not appear + expect(screen.queryByText("Show my recent jobs")).not.toBeInTheDocument(); + }); + + it("shows job-specific chips on a job sub-page", () => { + mockLocation("/jobs/42/visualizer/DNS"); + render(); + expect(screen.getByText("Summarize this job")).toBeInTheDocument(); + }); + + it("shows investigation-specific chips on an investigation page", () => { + mockLocation("/investigation/7"); + render(); + + expect( + screen.getByText("Summarize this investigation"), + ).toBeInTheDocument(); + expect(screen.getByText("Show investigation tree")).toBeInTheDocument(); + }); + + it("shows generic chips on non-entity pages", () => { + mockLocation("/dashboard"); + render(); + + expect(screen.getByText("Show my recent jobs")).toBeInTheDocument(); + expect(screen.getByText("List my investigations")).toBeInTheDocument(); + }); + + it("falls back to generic chips for non-numeric job id", () => { + mockLocation("/jobs/abc"); + render(); + expect(screen.getByText("Show my recent jobs")).toBeInTheDocument(); + }); + + it("calls onSend with resolved id on chip click", async () => { + mockLocation("/jobs/42"); + const onSend = jest.fn(); + render(); + + await userEvent.click(screen.getByText("Summarize this job")); + expect(onSend).toHaveBeenCalledWith("Summarize job #42"); + }); + + it("calls onSend with the raw message on generic pages", async () => { + mockLocation("/dashboard"); + const onSend = jest.fn(); + render(); + + await userEvent.click(screen.getByText("Show my recent jobs")); + expect(onSend).toHaveBeenCalledWith("Show my recent jobs"); + }); + + it("does not call onSend when disabled", async () => { + mockLocation("/jobs/42"); + const onSend = jest.fn(); + render(); + + await userEvent.click(screen.getByText("Summarize this job")); + expect(onSend).not.toHaveBeenCalled(); + }); + + describe("coupling: regexes match frontend Routes.jsx paths", () => { + // Mirror of the backend test_context.py coupling test. + // When Routes.jsx paths change, these regexes must change in lockstep + // with context.py — this test catches silent frontend-only drift. + + it("JOB_DETAIL_RE matches all job detail routes", () => { + [ + "/jobs/42", + "/jobs/42/visualizer", + "/jobs/42/visualizer/DNS", + "/jobs/42/comments", + ].forEach((path) => { + expect(JOB_DETAIL_RE.test(path)).toBe(true); + }); + }); + + it("INVESTIGATION_DETAIL_RE matches investigation detail routes", () => { + ["/investigation/7", "/investigation/7/something"].forEach((path) => { + expect(INVESTIGATION_DETAIL_RE.test(path)).toBe(true); + }); + }); + + it("neither regex matches non-entity paths", () => { + [ + "/dashboard", + "/plugins/analyzers", + "/history/jobs", + "/artifacts/3", + ].forEach((path) => { + expect(JOB_DETAIL_RE.test(path)).toBe(false); + expect(INVESTIGATION_DETAIL_RE.test(path)).toBe(false); + }); + }); + }); +}); diff --git a/tests/api_app/chatbot_manager/test_context.py b/tests/api_app/chatbot_manager/test_context.py index 1a1b1ad9a0..d259ffbeed 100644 --- a/tests/api_app/chatbot_manager/test_context.py +++ b/tests/api_app/chatbot_manager/test_context.py @@ -53,9 +53,12 @@ def test_regexes_match_frontend_route_definitions(self): """The regexes mirror React Router paths in frontend/src/components/Routes.jsx. When a frontend route changes (e.g. /jobs/:id → /analysis/:id), this test MUST - fail so the developer updates the regexes in context.py. The coupling is - explicit: the comment above _JOB_RE / _INVESTIGATION_RE lists the exact - Routes.jsx line numbers that define these paths. + fail so the developer updates the regexes in context.py AND in + frontend/src/components/chat/QuickActions.jsx (the frontend copy). The coupling + is explicit: the comments above the regexes in both files list the exact + Routes.jsx line numbers. Both sides have their own coupling test: + - backend: this test (test_context.py) + - frontend: tests/components/chat/QuickActions.test.jsx """ # Job detail routes (Routes.jsx:157,166,178,186) — /jobs/:id[/...] for path in ( From 9bcb7f048851d15b89eb3993b76af85b85d8e9b4 Mon Sep 17 00:00:00 2001 From: Francesco Berardi Date: Fri, 12 Jun 2026 20:44:45 +0200 Subject: [PATCH 2/2] feat(chatbot): broaden quick-action chips per review - rename 'What analyzers ran?' -> 'Which plugins ran?' (covers connectors/visualizers) - add 'Evaluate results' job chip - add 'Analyze this investigation' investigation chip Refs #3772 --- frontend/src/components/chat/QuickActions.jsx | 7 ++++++- .../tests/components/chat/QuickActions.test.jsx | 13 ++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/chat/QuickActions.jsx b/frontend/src/components/chat/QuickActions.jsx index ba94388b94..9106de97e3 100644 --- a/frontend/src/components/chat/QuickActions.jsx +++ b/frontend/src/components/chat/QuickActions.jsx @@ -12,8 +12,9 @@ export const INVESTIGATION_DETAIL_RE = /^\/investigation\/(\d+)(?:\/|$)/; const JOB_ACTIONS = [ { label: "Summarize this job", message: "Summarize job #{id}" }, - { label: "What analyzers ran?", message: "What analyzers ran on job #{id}?" }, + { label: "Which plugins ran?", message: "Which plugins ran on job #{id}?" }, { label: "Show job details", message: "Show me the details of job #{id}" }, + { label: "Evaluate results", message: "Evaluate the results of job #{id}" }, ]; const INVESTIGATION_ACTIONS = [ @@ -25,6 +26,10 @@ const INVESTIGATION_ACTIONS = [ label: "Show investigation tree", message: "Show the tree for investigation #{id}", }, + { + label: "Analyze this investigation", + message: "What can you tell me about investigation #{id}?", + }, ]; const GENERIC_ACTIONS = [ diff --git a/frontend/tests/components/chat/QuickActions.test.jsx b/frontend/tests/components/chat/QuickActions.test.jsx index 7f4980460f..399e388c64 100644 --- a/frontend/tests/components/chat/QuickActions.test.jsx +++ b/frontend/tests/components/chat/QuickActions.test.jsx @@ -30,8 +30,9 @@ describe("QuickActions", () => { render(); expect(screen.getByText("Summarize this job")).toBeInTheDocument(); - expect(screen.getByText("What analyzers ran?")).toBeInTheDocument(); + expect(screen.getByText("Which plugins ran?")).toBeInTheDocument(); expect(screen.getByText("Show job details")).toBeInTheDocument(); + expect(screen.getByText("Evaluate results")).toBeInTheDocument(); // generic chips must not appear expect(screen.queryByText("Show my recent jobs")).not.toBeInTheDocument(); }); @@ -50,6 +51,7 @@ describe("QuickActions", () => { screen.getByText("Summarize this investigation"), ).toBeInTheDocument(); expect(screen.getByText("Show investigation tree")).toBeInTheDocument(); + expect(screen.getByText("Analyze this investigation")).toBeInTheDocument(); }); it("shows generic chips on non-entity pages", () => { @@ -75,6 +77,15 @@ describe("QuickActions", () => { expect(onSend).toHaveBeenCalledWith("Summarize job #42"); }); + it("calls onSend with resolved id on Evaluate results click", async () => { + mockLocation("/jobs/42"); + const onSend = jest.fn(); + render(); + + await userEvent.click(screen.getByText("Evaluate results")); + expect(onSend).toHaveBeenCalledWith("Evaluate the results of job #42"); + }); + it("calls onSend with the raw message on generic pages", async () => { mockLocation("/dashboard"); const onSend = jest.fn();