From e62fe59aaa2ea0526a0cee455eb4273418e7be5e Mon Sep 17 00:00:00 2001 From: alejandrobailo Date: Fri, 26 Jun 2026 11:09:36 +0200 Subject: [PATCH] feat(ui): render Lighthouse tool calls as collapsible chain of thought --- .../_components/chat/message-bubble.tsx | 48 +++++--- .../_components/chat/streaming-message.tsx | 7 +- .../_components/chat/tool-call-part.tsx | 114 ++++++++++++++++++ .../lighthouse/_lib/tool-calls.test.ts | 77 ++++++++++++ .../(prowler)/lighthouse/_lib/tool-calls.ts | 50 ++++++++ .../(prowler)/lighthouse/_types/sessions.ts | 11 ++ 6 files changed, 287 insertions(+), 20 deletions(-) create mode 100644 ui/app/(prowler)/lighthouse/_components/chat/tool-call-part.tsx create mode 100644 ui/app/(prowler)/lighthouse/_lib/tool-calls.test.ts create mode 100644 ui/app/(prowler)/lighthouse/_lib/tool-calls.ts diff --git a/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.tsx b/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.tsx index 2246f2d729..aff6e91de9 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.tsx @@ -1,6 +1,6 @@ "use client"; -import { Bot, Check, Copy, UserRound, Wrench } from "lucide-react"; +import { Bot, Check, Copy, UserRound } from "lucide-react"; import { useState } from "react"; import { formatMessageTimestamp } from "@/app/(prowler)/lighthouse/_lib/format"; @@ -9,21 +9,19 @@ import { LIGHTHOUSE_V2_MESSAGE_ROLE, LIGHTHOUSE_V2_PART_TYPE, type LighthouseV2Message, + type LighthouseV2Part, } from "@/app/(prowler)/lighthouse/_types"; import { Button } from "@/components/shadcn/button/button"; import { cn } from "@/lib/utils"; import { MessageMarkdown } from "./message-markdown"; +import { ToolCalls } from "./tool-call-part"; export function MessageBubble({ message }: { message: LighthouseV2Message }) { const isUser = message.role === LIGHTHOUSE_V2_MESSAGE_ROLE.USER; - const textParts = message.parts.filter( - (part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT, - ); - const toolCallCount = message.parts.filter( - (part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TOOL_CALL, - ).length; - const messageText = textParts + // Text-only join feeds the copy button; tool calls are rendered separately. + const messageText = message.parts + .filter((part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT) .map((part) => getTextContent(part.content)) .filter(Boolean) .join("\n\n"); @@ -50,18 +48,12 @@ export function MessageBubble({ message }: { message: LighthouseV2Message }) { : "bg-bg-neutral-tertiary text-text-neutral-primary", )} > - {toolCallCount > 0 && ( -

- - {toolCallCount} {toolCallCount === 1 ? "tool" : "tools"} called -

- )} - {/* User text stays plain to preserve HTML-like tags; assistant text - renders as markdown. */} + {/* User text stays plain to preserve HTML-like tags; assistant + renders parts in order so tool calls sit between text blocks. */} {isUser ? (

{messageText}

) : ( - + )} part.type === LIGHTHOUSE_V2_PART_TYPE.TOOL_CALL, + ); + const textParts = parts.filter( + (part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT, + ); + + return ( +
+ + {textParts.map((part, index) => { + const text = getTextContent(part.content); + return text ? ( + + ) : null; + })} +
+ ); +} + function MessageMeta({ isUser, text, diff --git a/ui/app/(prowler)/lighthouse/_components/chat/streaming-message.tsx b/ui/app/(prowler)/lighthouse/_components/chat/streaming-message.tsx index 188f363ab0..48983fabb3 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/streaming-message.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/streaming-message.tsx @@ -12,6 +12,7 @@ import { LIGHTHOUSE_V2_STREAM_STATUS, type LighthouseV2StreamState, } from "@/app/(prowler)/lighthouse/_lib/event-reducer"; +import { formatToolName } from "@/app/(prowler)/lighthouse/_lib/tool-calls"; import { cn } from "@/lib/utils"; import { MessageMarkdown } from "./message-markdown"; @@ -97,7 +98,7 @@ function getActivityHeader(streamState: LighthouseV2StreamState): string { function getToolCallLabel( toolCall: LighthouseV2StreamState["toolCalls"][number], ): string { - return `${toolCall.status === "running" ? "Calling" : "Called"} ${ - toolCall.name - }`; + return `${toolCall.status === "running" ? "Calling" : "Called"} ${formatToolName( + toolCall.name, + )}`; } diff --git a/ui/app/(prowler)/lighthouse/_components/chat/tool-call-part.tsx b/ui/app/(prowler)/lighthouse/_components/chat/tool-call-part.tsx new file mode 100644 index 0000000000..64eb0606ba --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/chat/tool-call-part.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { Check, ChevronDown, TriangleAlert, Wrench } from "lucide-react"; + +import { + ChainOfThought, + ChainOfThoughtContent, + ChainOfThoughtHeader, +} from "@/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought"; +import { + formatToolName, + getToolCallContent, + isToolCallError, +} from "@/app/(prowler)/lighthouse/_lib/tool-calls"; +import type { LighthouseV2Part } from "@/app/(prowler)/lighthouse/_types"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/shadcn/collapsible"; +import { + QUERY_EDITOR_LANGUAGE, + QueryCodeEditor, +} from "@/components/shared/query-code-editor"; + +// Groups a finished message's tool calls under one collapsed disclosure so the +// "work" is hidden behind a single chevron by default, mirroring the streaming +// chain-of-thought. The answer text renders separately, always visible. +export function ToolCalls({ parts }: { parts: LighthouseV2Part[] }) { + if (parts.length === 0) { + return null; + } + const label = `Used ${parts.length} ${parts.length === 1 ? "tool" : "tools"}`; + + return ( + + + {label} + + + {parts.map((part, index) => ( + + ))} + + + ); +} + +// One tool call as a light, collapsed row: status + humanized name, expanding to +// the arguments the agent sent and the result it got back. +function ToolCallPart({ part }: { part: LighthouseV2Part }) { + const toolCall = getToolCallContent(part.content); + if (!toolCall) { + return null; + } + + // `content.outcome` is authoritative; fall back to the part-level column. + const outcome = toolCall.outcome ?? part.toolCallOutcome; + const isError = isToolCallError(outcome); + + return ( + + + {isError ? ( + + ) : ( + + )} + + + {formatToolName(toolCall.toolName)} + + {isError && outcome && ( + {outcome} + )} + + + + + + + + ); +} + +function ToolCallSection({ label, value }: { label: string; value: unknown }) { + if (value === null || value === undefined || value === "") { + return null; + } + // String results (often markdown) render as plain text so real newlines show + // instead of escaped "\n"; structured values render as highlighted JSON. + const isText = typeof value === "string"; + const text = isText ? value : JSON.stringify(value, null, 2); + if (!text) { + return null; + } + + return ( + {}} + /> + ); +} diff --git a/ui/app/(prowler)/lighthouse/_lib/tool-calls.test.ts b/ui/app/(prowler)/lighthouse/_lib/tool-calls.test.ts new file mode 100644 index 0000000000..570e7cc597 --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_lib/tool-calls.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { + formatToolName, + getToolCallContent, + isToolCallError, +} from "./tool-calls"; + +describe("getToolCallContent", () => { + it("should normalize the snake_case backend blob to camelCase", () => { + // Given + const content = { + tool_call_id: "call_1", + tool_name: "prowler_app_search_security_findings", + arguments: { severity: "high" }, + result: { count: 3 }, + outcome: "success", + }; + + // When + const parsed = getToolCallContent(content); + + // Then + expect(parsed).toEqual({ + toolCallId: "call_1", + toolName: "prowler_app_search_security_findings", + arguments: { severity: "high" }, + result: { count: 3 }, + outcome: "success", + }); + }); + + it("should default missing optional fields without throwing", () => { + // Given a blob with only the required tool_name + const parsed = getToolCallContent({ tool_name: "search_tools" }); + + // Then + expect(parsed).toEqual({ + toolCallId: "", + toolName: "search_tools", + arguments: null, + result: null, + outcome: null, + }); + }); + + it("should return null for non-tool-call content", () => { + expect(getToolCallContent(null)).toBeNull(); + expect(getToolCallContent("text")).toBeNull(); + expect(getToolCallContent({ text: "hi" })).toBeNull(); + }); +}); + +describe("formatToolName", () => { + it("should strip the prowler prefix and title-case", () => { + expect(formatToolName("prowler_app_search_security_findings")).toBe( + "Search security findings", + ); + expect(formatToolName("prowler_hub_list_checks")).toBe("List checks"); + }); + + it("should humanize prefix-less tools", () => { + expect(formatToolName("search_tools")).toBe("Search tools"); + }); +}); + +describe("isToolCallError", () => { + it("should treat success and absent outcomes as non-errors", () => { + expect(isToolCallError("success")).toBe(false); + expect(isToolCallError(null)).toBe(false); + }); + + it("should treat any other outcome as an error", () => { + expect(isToolCallError("timeout")).toBe(true); + expect(isToolCallError("mcp_tool_error")).toBe(true); + }); +}); diff --git a/ui/app/(prowler)/lighthouse/_lib/tool-calls.ts b/ui/app/(prowler)/lighthouse/_lib/tool-calls.ts new file mode 100644 index 0000000000..46034102d5 --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_lib/tool-calls.ts @@ -0,0 +1,50 @@ +import type { LighthouseV2ToolCallContent } from "@/app/(prowler)/lighthouse/_types"; + +// Prefixes shared by the MCP-sourced tools; stripped for display so a name like +// `prowler_app_search_security_findings` reads as "Search security findings". +const TOOL_NAME_PREFIXES = [ + "prowler_app_", + "prowler_hub_", + "prowler_docs_", +] as const; + +// Reads the snake_case TOOL_CALL blob the backend persists and normalizes it to +// the camelCase UI shape. Returns null when `content` isn't a tool-call object. +export function getToolCallContent( + content: unknown, +): LighthouseV2ToolCallContent | null { + if (typeof content !== "object" || content === null) { + return null; + } + const record = content as Record; + if (typeof record.tool_name !== "string") { + return null; + } + return { + toolCallId: + typeof record.tool_call_id === "string" ? record.tool_call_id : "", + toolName: record.tool_name, + arguments: record.arguments ?? null, + result: record.result ?? null, + outcome: typeof record.outcome === "string" ? record.outcome : null, + }; +} + +// Turns a raw tool name into a human label by dropping the known prefix and +// title-casing, e.g. `prowler_hub_list_checks` -> "List checks". A humanizer +// (not a hardcoded map) keeps this in sync as the MCP tool whitelist grows. +export function formatToolName(toolName: string): string { + const prefix = TOOL_NAME_PREFIXES.find((value) => toolName.startsWith(value)); + const stripped = prefix ? toolName.slice(prefix.length) : toolName; + const words = stripped.replace(/_/g, " ").trim(); + if (!words) { + return toolName; + } + return words.charAt(0).toUpperCase() + words.slice(1); +} + +// A tool call succeeded when its outcome is absent or the literal "success"; +// any other outcome is an error surfaced to the user. +export function isToolCallError(outcome: string | null): boolean { + return outcome !== null && outcome.toLowerCase() !== "success"; +} diff --git a/ui/app/(prowler)/lighthouse/_types/sessions.ts b/ui/app/(prowler)/lighthouse/_types/sessions.ts index b5f4ae89e6..600b3a65a6 100644 --- a/ui/app/(prowler)/lighthouse/_types/sessions.ts +++ b/ui/app/(prowler)/lighthouse/_types/sessions.ts @@ -35,6 +35,17 @@ export interface LighthouseV2Part { updatedAt: string | null; } +// Normalized shape of a TOOL_CALL part's `content`. The backend persists this +// blob in snake_case (tool_call_id, tool_name, ...); `getToolCallContent` +// maps it to this camelCase form so the UI never touches the raw keys. +export interface LighthouseV2ToolCallContent { + toolCallId: string; + toolName: string; + arguments: unknown; + result: unknown; + outcome: string | null; +} + export interface LighthouseV2Message { id: string; role: LighthouseV2MessageRole;