fix(ui): harden Lighthouse chat UI

This commit is contained in:
alejandrobailo
2026-06-30 17:21:06 +02:00
parent 599d962c0a
commit 0c2c05a2ea
8 changed files with 90 additions and 54 deletions
@@ -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.
}
};
+6 -1
View File
@@ -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
View File
@@ -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 `&lt;${tagName}&gt;`;
});
})
.join("");