Skip to content

Commit 0a4b12c

Browse files
pieman1313KokoMilev
authored andcommitted
feat(apollo-vertex): ai chat scroll
1 parent aec44ee commit 0a4b12c

6 files changed

Lines changed: 132 additions & 52 deletions

File tree

apps/apollo-vertex/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"rows_per_page": "Rows per page",
7676
"rows_selected": "{{selected}} of {{total}} row(s) selected.",
7777
"russian": "Russian",
78+
"scroll_to_bottom": "Scroll to bottom",
7879
"search": "Search...",
7980
"search_frameworks": "Search frameworks",
8081
"secondary": "Secondary",

apps/apollo-vertex/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"@dnd-kit/sortable": "^10.0.0",
2727
"@dnd-kit/utilities": "^3.2.2",
2828
"@hookform/resolvers": "^5.2.2",
29+
"@mantine/hooks": "^9.0.0",
2930
"@radix-ui/react-accordion": "^1.2.12",
3031
"@radix-ui/react-alert-dialog": "^1.1.15",
3132
"@radix-ui/react-aspect-ratio": "^1.1.8",

apps/apollo-vertex/registry.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@
250250
"title": "AI Chat",
251251
"description": "A composable AI chat UI component for TanStack AI with AgentHub adapter, markdown rendering, suggestion buttons, and error display",
252252
"dependencies": [
253+
"@mantine/hooks@^9.0.0",
253254
"@tanstack/ai@^0.8.1",
254255
"@tanstack/ai-client@^0.7.2",
255256
"@tanstack/ai-react@^0.7.2",
@@ -297,6 +298,11 @@
297298
"type": "registry:lib",
298299
"target": "components/ui/ai-chat/adapters/agenthub/tools.ts"
299300
},
301+
{
302+
"path": "registry/ai-chat/hooks/use-sticky-scroll.ts",
303+
"type": "registry:lib",
304+
"target": "components/ui/ai-chat/hooks/use-sticky-scroll.ts"
305+
},
300306
{
301307
"path": "registry/ai-chat/components/ai-chat.tsx",
302308
"type": "registry:ui",

apps/apollo-vertex/registry/ai-chat/components/ai-chat.tsx

Lines changed: 48 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"use client";
22

33
import type { UIMessage } from "@tanstack/ai-client";
4-
import { AlertCircle, Sparkles } from "lucide-react";
5-
import { type ReactNode, useEffect, useRef, useState } from "react";
4+
import { AlertCircle, ArrowDown, Sparkles } from "lucide-react";
5+
import { type ReactNode, useState } from "react";
66
import { useTranslation } from "react-i18next";
7+
import { useStickyScroll } from "../hooks/use-sticky-scroll";
78
import type { ChoiceOption } from "../types";
89
import { findLatestChoices } from "../utils/ai-chat-utils";
910
import { AiChatInput } from "./ai-chat-input";
@@ -43,28 +44,14 @@ export function AiChat({
4344
}: AiChatProps) {
4445
const { t } = useTranslation();
4546
const [input, setInput] = useState("");
46-
const scrollRef = useRef<HTMLDivElement>(null);
47-
const contentRef = useRef<HTMLDivElement>(null);
47+
const { scrollRef, contentRef, isStuck, scrollToBottom } = useStickyScroll();
4848
const displayName = assistantName ?? t("ai_assistant");
4949

50-
const scrollToBottom = () => {
51-
const el = scrollRef.current;
52-
if (el) el.scrollTop = el.scrollHeight;
53-
};
54-
55-
useEffect(() => {
56-
scrollToBottom();
57-
const last = contentRef.current?.lastElementChild;
58-
if (!last) return;
59-
const observer = new ResizeObserver(scrollToBottom);
60-
observer.observe(last);
61-
return () => observer.disconnect();
62-
}, [messages]);
63-
6450
const handleSubmit = () => {
6551
if (!input.trim() || isLoading) return;
6652
onSendMessage(input.trim());
6753
setInput("");
54+
scrollToBottom();
6855
};
6956

7057
const latestChoices = findLatestChoices(messages);
@@ -112,38 +99,51 @@ export function AiChat({
11299
</div>
113100
)}
114101

115-
<div
116-
ref={scrollRef}
117-
role="log"
118-
aria-label={t("chat_messages")}
119-
aria-live="polite"
120-
aria-atomic="false"
121-
className="flex-1 overflow-y-auto p-4"
122-
>
123-
{messages.length === 0 ? (
124-
(emptyState ?? defaultEmptyState)
125-
) : (
126-
<div ref={contentRef} className="space-y-4">
127-
{children}
102+
<div className="relative flex-1 min-h-0">
103+
<div
104+
ref={scrollRef}
105+
role="log"
106+
aria-label={t("chat_messages")}
107+
aria-live="polite"
108+
aria-atomic="false"
109+
className="h-full overflow-y-auto p-4"
110+
>
111+
{messages.length === 0 ? (
112+
(emptyState ?? defaultEmptyState)
113+
) : (
114+
<div ref={contentRef} className="space-y-4">
115+
{children}
128116

129-
{latestChoices && !isLoading && (
130-
<AiChatSuggestions
131-
prompt={latestChoices.prompt}
132-
options={latestChoices.options}
133-
onSelect={(option) => {
134-
if (onChoiceSelect) {
135-
onChoiceSelect(option);
136-
} else {
137-
onSendMessage(option.label);
138-
}
139-
}}
140-
/>
141-
)}
117+
{latestChoices && !isLoading && (
118+
<AiChatSuggestions
119+
prompt={latestChoices.prompt}
120+
options={latestChoices.options}
121+
onSelect={(option) => {
122+
if (onChoiceSelect) {
123+
onChoiceSelect(option);
124+
} else {
125+
onSendMessage(option.label);
126+
}
127+
}}
128+
/>
129+
)}
130+
131+
{showLoadingIndicator && (
132+
<AiChatLoading assistantName={displayName} />
133+
)}
134+
</div>
135+
)}
136+
</div>
142137

143-
{showLoadingIndicator && (
144-
<AiChatLoading assistantName={displayName} />
145-
)}
146-
</div>
138+
{!isStuck && (
139+
<button
140+
type="button"
141+
onClick={scrollToBottom}
142+
aria-label={t("scroll_to_bottom")}
143+
className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 flex items-center justify-center size-8 rounded-full border bg-background shadow-md hover:bg-accent"
144+
>
145+
<ArrowDown className="size-4" />
146+
</button>
147147
)}
148148
</div>
149149

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {
2+
useEventListener,
3+
useMergedRef,
4+
useResizeObserver,
5+
} from "@mantine/hooks";
6+
import { useCallback, useEffect, useRef, useState } from "react";
7+
8+
export function useStickyScroll() {
9+
const [isStuck, setIsStuck] = useState(true);
10+
const isStuckRef = useRef(true);
11+
const scrollElRef = useRef<HTMLDivElement | null>(null);
12+
13+
const setStuck = useCallback((stuck: boolean) => {
14+
isStuckRef.current = stuck;
15+
setIsStuck(stuck);
16+
}, []);
17+
18+
const scrollToBottom = useCallback(() => {
19+
const el = scrollElRef.current;
20+
if (!el) return;
21+
el.scrollTop = el.scrollHeight;
22+
setStuck(true);
23+
}, [setStuck]);
24+
25+
const handleWheel = useCallback(
26+
(e: WheelEvent) => {
27+
if (e.deltaY < 0) setStuck(false);
28+
},
29+
[setStuck],
30+
);
31+
32+
const handleScroll = useCallback(() => {
33+
if (isStuckRef.current) return;
34+
const el = scrollElRef.current;
35+
if (!el) return;
36+
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 1;
37+
if (atBottom) setStuck(true);
38+
}, [setStuck]);
39+
40+
const storeRef = useCallback((node: HTMLDivElement | null) => {
41+
scrollElRef.current = node;
42+
}, []);
43+
44+
const wheelRef = useEventListener("wheel", handleWheel, { passive: true });
45+
const scrollListenerRef = useEventListener("scroll", handleScroll, {
46+
passive: true,
47+
});
48+
const scrollRef = useMergedRef(storeRef, wheelRef, scrollListenerRef);
49+
50+
const [contentRef, contentRect] = useResizeObserver();
51+
52+
useEffect(() => {
53+
if (isStuckRef.current) {
54+
const el = scrollElRef.current;
55+
if (el) el.scrollTop = el.scrollHeight;
56+
}
57+
}, [contentRect.height]);
58+
59+
return { scrollRef, contentRef, isStuck, scrollToBottom };
60+
}

pnpm-lock.yaml

Lines changed: 16 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)