Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions frontend/src/components/chat/ChatPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -129,6 +136,7 @@ export function ChatPanel() {
) : (
<>
<ChatMessageList />
<QuickActions onSend={sendMessage} disabled={inputDisabled} />
<ChatComposer onSend={sendMessage} />
</>
)}
Expand Down
103 changes: 103 additions & 0 deletions frontend/src/components/chat/QuickActions.jsx
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}" },

Copy link
Copy Markdown
Member

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?

Copy link
Copy Markdown
Contributor Author

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}).

{ 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}",
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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,
};
141 changes: 141 additions & 0 deletions frontend/tests/components/chat/QuickActions.test.jsx
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);
});
});
});
});
9 changes: 6 additions & 3 deletions tests/api_app/chatbot_manager/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down