Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
Q
qjclaw-dmg
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
AI-甘富林
qjclaw-dmg
Commits
dc710cf3
Commit
dc710cf3
authored
Apr 30, 2026
by
AI-甘富林
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix(ui): keep conversation scrolled while streaming
parent
945b8992
Changes
1
Show whitespace changes
Inline
Side-by-side
Showing
1 changed file
with
79 additions
and
2 deletions
+79
-2
App.tsx
apps/ui/src/App.tsx
+79
-2
No files found.
apps/ui/src/App.tsx
View file @
dc710cf3
import
{
useEffect
,
useMemo
,
useRef
,
useState
}
from
"react"
;
import
{
useEffect
,
useMemo
,
useRef
,
useState
}
from
"react"
;
import
brandIcon
from
"./assets/brand-icon.png"
;
import
brandIcon
from
"./assets/brand-icon.png"
;
import
type
{
ReactNode
,
CSSProperties
,
ChangeEvent
,
DragEvent
as
ReactDragEvent
,
KeyboardEvent
as
ReactKeyboardEvent
,
PointerEvent
as
ReactPointerEvent
}
from
"react"
;
import
type
{
ReactNode
,
CSSProperties
,
ChangeEvent
,
DragEvent
as
ReactDragEvent
,
KeyboardEvent
as
ReactKeyboardEvent
,
PointerEvent
as
ReactPointerEvent
,
UIEvent
as
ReactUIEvent
}
from
"react"
;
import
type
{
import
type
{
AppConfig
,
AppConfig
,
ChatAttachment
,
ChatAttachment
,
...
@@ -132,6 +132,7 @@ const HOME_CHAT_PROJECT_ID = "home-chat";
...
@@ -132,6 +132,7 @@ const HOME_CHAT_PROJECT_ID = "home-chat";
const
EMPTY_SESSION_ID
=
""
;
const
EMPTY_SESSION_ID
=
""
;
const
SUCCESS_NOTICE_TIMEOUT_MS
=
2400
;
const
SUCCESS_NOTICE_TIMEOUT_MS
=
2400
;
const
TYPEWRITER_CHARS_PER_FRAME
=
3
;
const
TYPEWRITER_CHARS_PER_FRAME
=
3
;
const
MESSAGE_LIST_AUTO_SCROLL_THRESHOLD_PX
=
80
;
const
MAX_TRACE_ITEMS
=
60
;
const
MAX_TRACE_ITEMS
=
60
;
const
HOME_EXPERT_SUGGESTION_PROJECT_IDS
=
new
Set
([
"xhs"
,
"douyin"
]);
const
HOME_EXPERT_SUGGESTION_PROJECT_IDS
=
new
Set
([
"xhs"
,
"douyin"
]);
const
IMAGE_ATTACHMENT_EXTENSIONS
=
new
Set
([
".png"
,
".jpg"
,
".jpeg"
,
".webp"
,
".gif"
,
".bmp"
]);
const
IMAGE_ATTACHMENT_EXTENSIONS
=
new
Set
([
".png"
,
".jpg"
,
".jpeg"
,
".webp"
,
".gif"
,
".bmp"
]);
...
@@ -2224,6 +2225,9 @@ export default function App() {
...
@@ -2224,6 +2225,9 @@ export default function App() {
const skillMenuRef = useRef<HTMLDivElement | null>(null);
const skillMenuRef = useRef<HTMLDivElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const conversationWorkspaceRef = useRef<HTMLDivElement | null>(null);
const conversationWorkspaceRef = useRef<HTMLDivElement | null>(null);
const messageListRef = useRef<HTMLDivElement | null>(null);
const shouldAutoScrollMessageListRef = useRef(true);
const messageListScrollFrameRef = useRef<number | null>(null);
const copiedTokenResetRef = useRef<number | null>(null);
const copiedTokenResetRef = useRef<number | null>(null);
const composerDragDepthRef = useRef(0);
const composerDragDepthRef = useRef(0);
const composerResizeDragRef = useRef<{ startY: number; startHeight: number; workspaceHeight: number } | null>(null);
const composerResizeDragRef = useRef<{ startY: number; startHeight: number; workspaceHeight: number } | null>(null);
...
@@ -2240,6 +2244,47 @@ export default function App() {
...
@@ -2240,6 +2244,47 @@ export default function App() {
}));
}));
};
};
function isMessageListNearBottom(element = messageListRef.current): boolean {
if (!element) {
return true;
}
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
return distanceFromBottom <= MESSAGE_LIST_AUTO_SCROLL_THRESHOLD_PX;
}
function handleMessageListScroll(event: ReactUIEvent<HTMLDivElement>) {
shouldAutoScrollMessageListRef.current = isMessageListNearBottom(event.currentTarget);
}
function scrollMessageListToBottom(options: { force?: boolean; behavior?: ScrollBehavior } = {}) {
const element = messageListRef.current;
if (!element) {
return;
}
const shouldScroll = options.force || shouldAutoScrollMessageListRef.current || isMessageListNearBottom(element);
if (!shouldScroll) {
return;
}
shouldAutoScrollMessageListRef.current = true;
if (messageListScrollFrameRef.current) {
window.cancelAnimationFrame(messageListScrollFrameRef.current);
}
messageListScrollFrameRef.current = window.requestAnimationFrame(() => {
messageListScrollFrameRef.current = null;
const latestElement = messageListRef.current;
if (!latestElement) {
return;
}
latestElement.scrollTo({
top: latestElement.scrollHeight,
behavior: options.behavior ?? "smooth"
});
});
}
const minimizeWindow = () => void desktopApi.window.minimize();
const minimizeWindow = () => void desktopApi.window.minimize();
const maximizeWindow = () => void desktopApi.window.maximize();
const maximizeWindow = () => void desktopApi.window.maximize();
const catalogSkills = workspace?.skills ?? [];
const catalogSkills = workspace?.skills ?? [];
...
@@ -2406,6 +2451,34 @@ export default function App() {
...
@@ -2406,6 +2451,34 @@ export default function App() {
return () => observer.disconnect();
return () => observer.disconnect();
}, [composerTextareaRatio, isConversationView]);
}, [composerTextareaRatio, isConversationView]);
useEffect(() => {
return () => {
if (messageListScrollFrameRef.current) {
window.cancelAnimationFrame(messageListScrollFrameRef.current);
messageListScrollFrameRef.current = null;
}
};
}, []);
useEffect(() => {
if (!isConversationView || !visibleSessionId) {
return;
}
shouldAutoScrollMessageListRef.current = true;
scrollMessageListToBottom({ force: true, behavior: "auto" });
}, [isConversationView, visibleSessionId]);
useEffect(() => {
if (!isConversationView) {
return;
}
scrollMessageListToBottom({
behavior: sendPhase === "streaming" ? "auto" : "smooth"
});
}, [isConversationView, messageTraces, messages, sendPhase]);
useEffect(() => {
useEffect(() => {
if (viewMode !== "chat" && viewMode !== "experts") {
if (viewMode !== "chat" && viewMode !== "experts") {
clearComposerAttachment();
clearComposerAttachment();
...
@@ -3503,6 +3576,7 @@ export default function App() {
...
@@ -3503,6 +3576,7 @@ export default function App() {
statusLabel: undefined,
statusLabel: undefined,
statusDetail: undefined
statusDetail: undefined
}));
}));
scrollMessageListToBottom({ behavior: "auto" });
}
}
if (currentStream.renderedText.length < currentStream.targetText.length) {
if (currentStream.renderedText.length < currentStream.targetText.length) {
...
@@ -3899,6 +3973,7 @@ export default function App() {
...
@@ -3899,6 +3973,7 @@ export default function App() {
if (optimisticSessionId) {
if (optimisticSessionId) {
updateSessionMessages(optimisticSessionId, (current) => [...current, userMessage, assistantMessage]);
updateSessionMessages(optimisticSessionId, (current) => [...current, userMessage, assistantMessage]);
scrollMessageListToBottom({ force: true, behavior: "smooth" });
}
}
try {
try {
...
@@ -3926,8 +4001,10 @@ export default function App() {
...
@@ -3926,8 +4001,10 @@ export default function App() {
}
}
if (!optimisticSessionId) {
if (!optimisticSessionId) {
updateSessionMessages(sessionId, (current) => [...current, userMessage, assistantMessage]);
updateSessionMessages(sessionId, (current) => [...current, userMessage, assistantMessage]);
scrollMessageListToBottom({ force: true, behavior: "smooth" });
} else if (optimisticSessionId !== sessionId) {
} else if (optimisticSessionId !== sessionId) {
moveSessionMessages(optimisticSessionId, sessionId, [userMessageId, assistantMessageId]);
moveSessionMessages(optimisticSessionId, sessionId, [userMessageId, assistantMessageId]);
scrollMessageListToBottom({ force: true, behavior: "smooth" });
}
}
updateStreamSmoke(() => ({
updateStreamSmoke(() => ({
...
@@ -4721,7 +4798,7 @@ export default function App() {
...
@@ -4721,7 +4798,7 @@ export default function App() {
);
);
const messageListContent = (
const messageListContent = (
<div className={"message-list chat-scroll-smooth" + (viewMode === "chat" ? " message-list-home" : "")}>
<div
ref={messageListRef} onScroll={handleMessageListScroll}
className={"message-list chat-scroll-smooth" + (viewMode === "chat" ? " message-list-home" : "")}>
{messages.map((message) => {
{messages.map((message) => {
const showThinking = message.role === "assistant" && message.streamState === "streaming" && !message.content.trim();
const showThinking = message.role === "assistant" && message.streamState === "streaming" && !message.content.trim();
const videoStatusCard = showThinking ? buildDouyinVideoStatusCard(message, activeExpertKey) : null;
const videoStatusCard = showThinking ? buildDouyinVideoStatusCard(message, activeExpertKey) : null;
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment