diff --git a/ui/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought.tsx b/ui/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought.tsx index 35e0539b4a..474fed577c 100644 --- a/ui/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought.tsx +++ b/ui/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought.tsx @@ -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 ( -
+ {children} -
+
); } @@ -77,37 +85,42 @@ export function ChainOfThoughtHeader({ children, ...props }: ChainOfThoughtHeaderProps) { - const { isOpen, setIsOpen } = useChainOfThought(); + const { isOpen } = useChainOfThought(); return ( - - + + {children ?? "Chain of Thought"} + - - - {children ?? "Chain of Thought"} - - - - + /> + ); } +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 ( - - - {children} - - + + {children} + ); } diff --git a/ui/app/(prowler)/lighthouse/_components/ai-elements/conversation.tsx b/ui/app/(prowler)/lighthouse/_components/ai-elements/conversation.tsx index ec96807893..f1adfa3689 100644 --- a/ui/app/(prowler)/lighthouse/_components/ai-elements/conversation.tsx +++ b/ui/app/(prowler)/lighthouse/_components/ai-elements/conversation.tsx @@ -41,7 +41,10 @@ export const ConversationContent = ({ const { contentRef, scrollRef } = context; return ( -
+
{ - 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); } diff --git a/ui/app/(prowler)/lighthouse/_components/navigation/lighthouse-v2-sidebar-chat.tsx b/ui/app/(prowler)/lighthouse/_components/navigation/lighthouse-v2-sidebar-chat.tsx index c08b6ec940..1ffa980b8e 100644 --- a/ui/app/(prowler)/lighthouse/_components/navigation/lighthouse-v2-sidebar-chat.tsx +++ b/ui/app/(prowler)/lighthouse/_components/navigation/lighthouse-v2-sidebar-chat.tsx @@ -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. } }; diff --git a/ui/app/(prowler)/lighthouse/_lib/messages.ts b/ui/app/(prowler)/lighthouse/_lib/messages.ts index c23b8242ea..4294660be6 100644 --- a/ui/app/(prowler)/lighthouse/_lib/messages.ts +++ b/ui/app/(prowler)/lighthouse/_lib/messages.ts @@ -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, diff --git a/ui/app/(prowler)/lighthouse/settings/page.tsx b/ui/app/(prowler)/lighthouse/settings/page.tsx index 2df2e00923..057161bc10 100644 --- a/ui/app/(prowler)/lighthouse/settings/page.tsx +++ b/ui/app/(prowler)/lighthouse/settings/page.tsx @@ -1,5 +1,3 @@ -import { Spacer } from "@heroui/spacer"; - import { getLighthouseV2Configurations, getLighthouseV2SupportedProviders, @@ -45,7 +43,7 @@ export default async function LighthouseSettingsPage() { return ( - +