feat(ui): render Lighthouse tool calls as collapsible chain of thought

This commit is contained in:
alejandrobailo
2026-06-26 11:09:36 +02:00
parent 5323b3475e
commit e62fe59aaa
6 changed files with 287 additions and 20 deletions
@@ -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 && (
<p className="text-text-neutral-secondary mb-2 flex items-center gap-1.5 text-xs">
<Wrench className="size-3.5" />
{toolCallCount} {toolCallCount === 1 ? "tool" : "tools"} called
</p>
)}
{/* 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 ? (
<p className="whitespace-pre-wrap">{messageText}</p>
) : (
<MessageMarkdown text={messageText} />
<AssistantParts parts={message.parts} />
)}
</div>
<MessageMeta
@@ -77,6 +69,28 @@ export function MessageBubble({ message }: { message: LighthouseV2Message }) {
);
}
function AssistantParts({ parts }: { parts: LighthouseV2Part[] }) {
// Tool calls collapse into one disclosure (the "work"); text is the answer.
const toolParts = parts.filter(
(part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TOOL_CALL,
);
const textParts = parts.filter(
(part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT,
);
return (
<div className="space-y-3">
<ToolCalls parts={toolParts} />
{textParts.map((part, index) => {
const text = getTextContent(part.content);
return text ? (
<MessageMarkdown key={part.id || `text-${index}`} text={text} />
) : null;
})}
</div>
);
}
function MessageMeta({
isUser,
text,
@@ -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,
)}`;
}
@@ -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 (
<ChainOfThought className="space-y-0">
<ChainOfThoughtHeader className="text-text-neutral-secondary">
{label}
</ChainOfThoughtHeader>
<ChainOfThoughtContent className="mt-2 space-y-1.5">
{parts.map((part, index) => (
<ToolCallPart key={part.id || `tool-${index}`} part={part} />
))}
</ChainOfThoughtContent>
</ChainOfThought>
);
}
// 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 (
<Collapsible className="border-border-neutral-secondary rounded-[6px] border">
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-2.5 py-1.5 text-xs">
{isError ? (
<TriangleAlert className="text-text-error-primary size-3.5 shrink-0" />
) : (
<Check className="text-text-success-primary size-3.5 shrink-0" />
)}
<Wrench className="text-text-neutral-tertiary size-3.5 shrink-0" />
<span className="text-text-neutral-primary flex-1 truncate text-left">
{formatToolName(toolCall.toolName)}
</span>
{isError && outcome && (
<span className="text-text-error-primary truncate">{outcome}</span>
)}
<ChevronDown className="text-text-neutral-tertiary size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 px-2.5 pb-2.5">
<ToolCallSection label="Arguments" value={toolCall.arguments} />
<ToolCallSection label="Result" value={toolCall.result} />
</CollapsibleContent>
</Collapsible>
);
}
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 (
<QueryCodeEditor
ariaLabel={label}
visibleLabel={label}
language={
isText ? QUERY_EDITOR_LANGUAGE.PLAIN_TEXT : QUERY_EDITOR_LANGUAGE.JSON
}
value={text}
copyValue={text}
editable={false}
minHeight={0}
showCopyButton
showLineNumbers={false}
onChange={() => {}}
/>
);
}
@@ -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);
});
});
@@ -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<string, unknown>;
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";
}
@@ -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;