Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/docs/api/appkit-ui/genie/GenieChatMessageList.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Scrollable message list that renders Genie chat messages with auto-scroll, skele
| `messages` | `GenieMessageItem[]` | ✓ | - | Array of messages to display |
| `status` | `enum` | ✓ | - | Current chat status (controls loading indicators and skeleton placeholders) |
| `className` | `string` | | - | Additional CSS class for the scroll area |
| `hasPreviousPage` | `boolean` | | `false` | Whether a previous page of older messages exists |
| `onFetchPreviousPage` | `(() => void)` | | - | Callback to fetch the previous page of messages |



Expand Down
182 changes: 153 additions & 29 deletions packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useLayoutEffect, useRef } from "react";
import { cn } from "../lib/utils";
import { ScrollArea } from "../ui/scroll-area";
import { Skeleton } from "../ui/skeleton";
Expand All @@ -13,6 +13,10 @@ export interface GenieChatMessageListProps {
status: GenieChatStatus;
/** Additional CSS class for the scroll area */
className?: string;
/** Whether a previous page of older messages exists */
hasPreviousPage?: boolean;
/** Callback to fetch the previous page of messages */
onFetchPreviousPage?: () => void;
}

const STATUS_LABELS: Record<string, string> = {
Expand All @@ -26,41 +30,157 @@ function formatStatus(status: string): string {
return STATUS_LABELS[status] ?? status.replace(/_/g, " ").toLowerCase();
}

function StreamingIndicator({ messages }: { messages: GenieMessageItem[] }) {
const last = messages[messages.length - 1];
if (last?.role === "assistant" && last.id === "") {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground px-11">
<Spinner className="h-3 w-3" />
<span>{formatStatus(last.status)}</span>
</div>
function getViewport(scrollRef: React.RefObject<HTMLDivElement | null>) {
return scrollRef.current?.querySelector<HTMLElement>(
'[data-slot="scroll-area-viewport"]',
);
}

/**
* Manages scroll position: scrolls to bottom on append/initial load,
* preserves position when older messages are prepended.
*/
function useScrollManagement(
scrollRef: React.RefObject<HTMLDivElement | null>,
messages: GenieMessageItem[],
) {
const prevFirstMessageIdRef = useRef<string | null>(null);
const prevScrollHeightRef = useRef(0);
const prevMessageCountRef = useRef(0);

// Keep prevScrollHeightRef fresh when async content (images, embeds)
// changes the viewport height between renders.
useEffect(() => {
const viewport = getViewport(scrollRef);
if (!viewport) return;

const observer = new ResizeObserver(() => {
prevScrollHeightRef.current = viewport.scrollHeight;
});
observer.observe(viewport);
return () => observer.disconnect();
}, [scrollRef]);

// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only react to message count changes
useLayoutEffect(() => {
const viewport = getViewport(scrollRef);
if (!viewport) return;

const count = messages.length;
const countChanged = count !== prevMessageCountRef.current;
prevMessageCountRef.current = count;

// Nothing to do if message count didn't change (e.g. status-only transition)
if (!countChanged) {
prevScrollHeightRef.current = viewport.scrollHeight;
return;
}

const firstMessageId = messages[0]?.id ?? null;
const wasPrepend =
prevFirstMessageIdRef.current !== null &&
firstMessageId !== prevFirstMessageIdRef.current;

if (wasPrepend && prevScrollHeightRef.current > 0) {
// Older messages prepended — preserve scroll position
const delta = viewport.scrollHeight - prevScrollHeightRef.current;
viewport.scrollTop += delta;
} else {
// Messages appended or initial load — scroll to bottom
viewport.scrollTop = viewport.scrollHeight;
}

prevFirstMessageIdRef.current = firstMessageId;
prevScrollHeightRef.current = viewport.scrollHeight;
}, [messages.length]);
}

/**
* Observes a sentinel element at the top of the scroll area and triggers
* `onFetchPreviousPage` when the user scrolls to the top (only if content overflows).
* Returns a ref to attach to the sentinel element.
*/
function useLoadOlderOnScroll(
scrollRef: React.RefObject<HTMLDivElement | null>,
shouldObserve: boolean,
onFetchPreviousPage?: () => void,
) {
const sentinelRef = useRef<HTMLDivElement>(null);
const onFetchPreviousPageRef = useRef(onFetchPreviousPage);
onFetchPreviousPageRef.current = onFetchPreviousPage;

useEffect(() => {
const sentinel = sentinelRef.current;
const viewport = getViewport(scrollRef);
if (!sentinel || !viewport || !shouldObserve) return;

// The observer fires synchronously on observe() if the sentinel is
// already visible. We arm it on the next frame so that synchronous
// initial fire is ignored, but a real intersection (user genuinely
// at the top on a short conversation) triggers on subsequent frames.
let armed = false;
const frameId = requestAnimationFrame(() => {
armed = true;
});

const observer = new IntersectionObserver(
(entries) => {
if (!armed) return;
const isScrollable = viewport.scrollHeight > viewport.clientHeight;
if (entries[0]?.isIntersecting && isScrollable) {
onFetchPreviousPageRef.current?.();
}
},
{ root: viewport, threshold: 0 },
);
}
return null;

observer.observe(sentinel);
return () => {
cancelAnimationFrame(frameId);
observer.disconnect();
};
}, [scrollRef, shouldObserve]);

return sentinelRef;
}

/** Scrollable message list that renders Genie chat messages with auto-scroll, skeleton loaders, and a streaming indicator. */
export function GenieChatMessageList({
messages,
status,
className,
hasPreviousPage = false,
onFetchPreviousPage,
}: GenieChatMessageListProps) {
const scrollRef = useRef<HTMLDivElement>(null);

// Scroll only the ScrollArea viewport, not the page
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional triggers for auto-scroll
useEffect(() => {
const viewport = scrollRef.current?.querySelector<HTMLElement>(
'[data-slot="scroll-area-viewport"]',
);
if (viewport) {
viewport.scrollTop = viewport.scrollHeight;
}
}, [messages.length, status]);
const sentinelRef = useLoadOlderOnScroll(
scrollRef,
hasPreviousPage && status !== "loading-older",
onFetchPreviousPage,
);
useScrollManagement(scrollRef, messages);

const lastMessage = messages[messages.length - 1];
const showStreamingIndicator =
status === "streaming" &&
lastMessage?.role === "assistant" &&
lastMessage.id === "";

return (
<ScrollArea ref={scrollRef} className={cn("flex-1 min-h-0 p-4", className)}>
<div className="flex flex-col gap-4">
{hasPreviousPage && <div ref={sentinelRef} className="h-px" />}

{status === "loading-older" && (
<div className="flex items-center justify-center gap-2 py-2">
<Spinner className="h-3 w-3" />
<span className="text-sm text-muted-foreground">
Loading older messages...
</span>
</div>
)}

{status === "loading-history" && messages.length === 0 && (
<div className="flex flex-col gap-4">
<Skeleton className="h-12 w-3/4" />
Expand All @@ -69,15 +189,19 @@ export function GenieChatMessageList({
</div>
)}

{messages.map((msg) => {
if (msg.role === "assistant" && msg.id === "" && !msg.content) {
return null;
}
return <GenieChatMessage key={msg.id} message={msg} />;
})}
{messages
.filter(
(msg) => msg.role !== "assistant" || msg.id !== "" || msg.content,
)
.map((msg) => (
<GenieChatMessage key={msg.id} message={msg} />
))}

{status === "streaming" && messages.length > 0 && (
<StreamingIndicator messages={messages} />
{showStreamingIndicator && (
<div className="flex items-center gap-2 text-sm text-muted-foreground px-11">
<Spinner className="h-3 w-3" />
<span>{formatStatus(lastMessage.status)}</span>
</div>
)}

{messages.length === 0 && status === "idle" && (
Expand Down
17 changes: 15 additions & 2 deletions packages/appkit-ui/src/react/genie/genie-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,15 @@ export function GenieChat({
placeholder,
className,
}: GenieChatProps) {
const { messages, status, error, sendMessage, reset } = useGenieChat({
const {
messages,
status,
error,
sendMessage,
reset,
hasPreviousPage,
fetchPreviousPage,
} = useGenieChat({
alias,
basePath,
});
Expand All @@ -32,7 +40,12 @@ export function GenieChat({
</div>
)}

<GenieChatMessageList messages={messages} status={status} />
<GenieChatMessageList
messages={messages}
status={status}
hasPreviousPage={hasPreviousPage}
onFetchPreviousPage={fetchPreviousPage}
/>

{error && (
<div className="shrink-0 px-4 py-2 text-sm text-destructive bg-destructive/10 border-t">
Expand Down
7 changes: 7 additions & 0 deletions packages/appkit-ui/src/react/genie/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type {
export type GenieChatStatus =
| "idle"
| "loading-history"
| "loading-older"
| "streaming"
| "error";

Expand Down Expand Up @@ -40,6 +41,12 @@ export interface UseGenieChatReturn {
error: string | null;
sendMessage: (content: string) => void;
reset: () => void;
/** Whether a previous page of older messages exists */
hasPreviousPage: boolean;
/** Whether a previous page is currently being fetched */
isFetchingPreviousPage: boolean;
/** Fetch the previous page of older messages */
fetchPreviousPage: () => void;
}

export interface GenieChatProps {
Expand Down
Loading