-
-
Notifications
You must be signed in to change notification settings - Fork 643
[GSoC 2026] Add context-aware quick-action buttons to the chat panel (refs #3772) #3773
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: gsoc-2026/llm-chatbot
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| 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: "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 = [ | ||
| { | ||
| label: "Summarize this investigation", | ||
| message: "Summarize investigation #{id}", | ||
| }, | ||
| { | ||
| label: "Show investigation tree", | ||
| message: "Show the tree for investigation #{id}", | ||
| }, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. also here, some query that allows the LLM to try to guess something about the content? is the summarization more generic right?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added the "Analyze this investigation" chip (What can you tell me about investigation #{id}?). |
||
| { | ||
| label: "Analyze this investigation", | ||
| message: "What can you tell me about 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 ( | ||
| <div className="d-flex flex-wrap gap-1 p-2" id="quick-actions"> | ||
| {actions.map((action) => ( | ||
| <Button | ||
| key={action.label} | ||
| color="outline-secondary" | ||
| size="sm" | ||
| disabled={disabled} | ||
| onClick={() => handleClick(action.message)} | ||
| > | ||
| {action.label} | ||
| </Button> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| QuickActions.propTypes = { | ||
| onSend: PropTypes.func.isRequired, | ||
| disabled: PropTypes.bool, | ||
| }; | ||
|
|
||
| QuickActions.defaultProps = { | ||
| disabled: false, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| 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(<QuickActions onSend={jest.fn()} />); | ||
|
|
||
| expect(screen.getByText("Summarize this job")).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(); | ||
| }); | ||
|
|
||
| it("shows job-specific chips on a job sub-page", () => { | ||
| mockLocation("/jobs/42/visualizer/DNS"); | ||
| render(<QuickActions onSend={jest.fn()} />); | ||
| expect(screen.getByText("Summarize this job")).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("shows investigation-specific chips on an investigation page", () => { | ||
| mockLocation("/investigation/7"); | ||
| render(<QuickActions onSend={jest.fn()} />); | ||
|
|
||
| expect( | ||
| 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", () => { | ||
| mockLocation("/dashboard"); | ||
| render(<QuickActions onSend={jest.fn()} />); | ||
|
|
||
| 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(<QuickActions onSend={jest.fn()} />); | ||
| 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(<QuickActions onSend={onSend} />); | ||
|
|
||
| await userEvent.click(screen.getByText("Summarize this job")); | ||
| 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(<QuickActions onSend={onSend} />); | ||
|
|
||
| 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(); | ||
| render(<QuickActions onSend={onSend} />); | ||
|
|
||
| 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(<QuickActions onSend={onSend} disabled />); | ||
|
|
||
| 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); | ||
| }); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
adding something like "evaluate job results" does it make sense here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added the "Evaluate results" chip (Evaluate the results of job #{id}).