mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
fix(ui): harden Lighthouse chat UI
This commit is contained in:
@@ -59,11 +59,19 @@ export function ChainOfThought({
|
||||
|
||||
const chainOfThoughtContext = { isOpen, setIsOpen };
|
||||
|
||||
// One Collapsible root wraps both header and content so CollapsibleTrigger and
|
||||
// CollapsibleContent share Radix's ARIA/id wiring; the context only carries
|
||||
// `isOpen` for presentational bits like the header chevron.
|
||||
return (
|
||||
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
|
||||
<div className={cn("not-prose w-full space-y-4", className)} {...props}>
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
className={cn("not-prose w-full space-y-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</ChainOfThoughtContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -77,37 +85,42 @@ export function ChainOfThoughtHeader({
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtHeaderProps) {
|
||||
const { isOpen, setIsOpen } = useChainOfThought();
|
||||
const { isOpen } = useChainOfThought();
|
||||
|
||||
return (
|
||||
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
|
||||
<CollapsibleTrigger
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<BrainIcon className="size-4" />
|
||||
<span className="flex-1 text-left">{children ?? "Chain of Thought"}</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors",
|
||||
className,
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<BrainIcon className="size-4" />
|
||||
<span className="flex-1 text-left">
|
||||
{children ?? "Chain of Thought"}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export const CHAIN_OF_THOUGHT_STATUS = {
|
||||
COMPLETE: "complete",
|
||||
ACTIVE: "active",
|
||||
PENDING: "pending",
|
||||
} as const;
|
||||
|
||||
export type ChainOfThoughtStatus =
|
||||
(typeof CHAIN_OF_THOUGHT_STATUS)[keyof typeof CHAIN_OF_THOUGHT_STATUS];
|
||||
|
||||
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
|
||||
icon?: LucideIcon;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
status?: "complete" | "active" | "pending";
|
||||
status?: ChainOfThoughtStatus;
|
||||
};
|
||||
|
||||
export function ChainOfThoughtStep({
|
||||
@@ -115,7 +128,7 @@ export function ChainOfThoughtStep({
|
||||
icon: Icon = DotIcon,
|
||||
label,
|
||||
description,
|
||||
status = "complete",
|
||||
status = CHAIN_OF_THOUGHT_STATUS.COMPLETE,
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtStepProps) {
|
||||
@@ -191,21 +204,17 @@ export function ChainOfThoughtContent({
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtContentProps) {
|
||||
const { isOpen } = useChainOfThought();
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen}>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-2 space-y-3",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-2 space-y-3",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,10 @@ export const ConversationContent = ({
|
||||
const { contentRef, scrollRef } = context;
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className={cn("h-full w-full", scrollClassName)}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn("h-full min-h-0 w-full overflow-y-auto", scrollClassName)}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn("flex flex-col gap-8 p-4", className)}
|
||||
|
||||
@@ -119,7 +119,13 @@ function ChatComposer({
|
||||
textareaSize="lg"
|
||||
className="min-h-[104px] flex-1"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
// Ignore Enter while an IME composition is active so confirming an
|
||||
// East Asian candidate doesn't submit the message prematurely.
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
!event.shiftKey &&
|
||||
!event.nativeEvent.isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
void onSubmitText(input);
|
||||
}
|
||||
|
||||
@@ -31,9 +31,14 @@ export function LighthouseV2SidebarChat({ isOpen }: { isOpen: boolean }) {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const refreshSessions = async () => {
|
||||
const result = await getLighthouseV2Sessions();
|
||||
if ("data" in result) {
|
||||
setSessions(result.data);
|
||||
try {
|
||||
const result = await getLighthouseV2Sessions();
|
||||
if ("data" in result) {
|
||||
setSessions(result.data);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort refresh: swallow transport-level failures so a rejected
|
||||
// server action never escapes the mount effect as an unhandled error.
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,11 +57,15 @@ export function LighthouseV2SidebarChat({ isOpen }: { isOpen: boolean }) {
|
||||
};
|
||||
|
||||
const handleArchiveSession = async (sessionId: string) => {
|
||||
const result = await archiveLighthouseV2Session(sessionId);
|
||||
if ("data" in result) {
|
||||
setSessions((current) =>
|
||||
current.filter((session) => session.id !== sessionId),
|
||||
);
|
||||
try {
|
||||
const result = await archiveLighthouseV2Session(sessionId);
|
||||
if ("data" in result) {
|
||||
setSessions((current) =>
|
||||
current.filter((session) => session.id !== sessionId),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Archiving is recoverable from the sidebar; ignore transient failures.
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ export function getTextContent(content: unknown): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Monotonic counter guaranteeing unique optimistic ids even when two messages
|
||||
// are built within the same millisecond (toISOString alone is ms-granular).
|
||||
let optimisticMessageCounter = 0;
|
||||
|
||||
// Builds a client-only message shown immediately after submit, before the
|
||||
// backend echoes the persisted message back through the stream/refresh.
|
||||
export function buildOptimisticMessage(
|
||||
@@ -28,7 +32,8 @@ export function buildOptimisticMessage(
|
||||
text: string,
|
||||
): LighthouseV2Message {
|
||||
const now = new Date().toISOString();
|
||||
const id = `optimistic-${role}-${now}`;
|
||||
optimisticMessageCounter += 1;
|
||||
const id = `optimistic-${role}-${now}-${optimisticMessageCounter}`;
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Spacer } from "@heroui/spacer";
|
||||
|
||||
import {
|
||||
getLighthouseV2Configurations,
|
||||
getLighthouseV2SupportedProviders,
|
||||
@@ -45,7 +43,7 @@ export default async function LighthouseSettingsPage() {
|
||||
return (
|
||||
<ContentLayout title="Settings">
|
||||
<LLMProvidersTable />
|
||||
<Spacer y={8} />
|
||||
<div className="h-8" aria-hidden="true" />
|
||||
<LighthouseSettings />
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
@@ -41,7 +41,10 @@ export const ConversationContent = ({
|
||||
const { contentRef, scrollRef } = context;
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className={cn("h-full w-full", scrollClassName)}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn("h-full min-h-0 w-full overflow-y-auto", scrollClassName)}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn("flex flex-col gap-8 p-4", className)}
|
||||
|
||||
+5
-2
@@ -59,12 +59,15 @@ export function escapeAngleBracketPlaceholders(text: string): string {
|
||||
return part;
|
||||
}
|
||||
|
||||
// For regular text outside code, wrap placeholders in backticks.
|
||||
// For regular text outside code, escape placeholders as HTML entities so
|
||||
// they render as plain `<bucket_name>` text. Raw HTML parsing is disabled
|
||||
// in both chat renderers, so entities are enough — and avoid the code-span
|
||||
// styling that wrapping in backticks would force.
|
||||
return part.replace(/<([a-zA-Z][a-zA-Z0-9_-]*)>/g, (match, tagName) => {
|
||||
if (htmlTags.has(tagName.toLowerCase())) {
|
||||
return match;
|
||||
}
|
||||
return `\`<${tagName}>\``;
|
||||
return `<${tagName}>`;
|
||||
});
|
||||
})
|
||||
.join("");
|
||||
|
||||
Reference in New Issue
Block a user