Skip to content

Commit f709a5f

Browse files
committed
fix: scrolling safari issues
1 parent 1821cf4 commit f709a5f

File tree

9 files changed

+1059
-759
lines changed

9 files changed

+1059
-759
lines changed

packages/ai-chat/src/chat/components-legacy/MessagesComponent.tsx

Lines changed: 323 additions & 696 deletions
Large diffs are not rendered by default.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright IBM Corp. 2025
3+
*
4+
* This source code is licensed under the Apache-2.0 license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @license
8+
*/
9+
10+
import React from "react";
11+
12+
interface MessagesScrollHandleProps {
13+
ariaLabel: string;
14+
buttonRef: React.RefObject<HTMLButtonElement>;
15+
onBlur: () => void;
16+
onClick?: () => void;
17+
onFocus: () => void;
18+
}
19+
20+
function MessagesScrollHandle({
21+
ariaLabel,
22+
buttonRef,
23+
onBlur,
24+
onClick,
25+
onFocus,
26+
}: MessagesScrollHandleProps) {
27+
return (
28+
<button
29+
type="button"
30+
className="cds-aichat--messages--scroll-handle"
31+
ref={buttonRef}
32+
tabIndex={0}
33+
aria-label={ariaLabel}
34+
onClick={onClick}
35+
onFocus={onFocus}
36+
onBlur={onBlur}
37+
/>
38+
);
39+
}
40+
41+
export { MessagesScrollHandle };
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright IBM Corp. 2025
3+
*
4+
* This source code is licensed under the Apache-2.0 license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @license
8+
*/
9+
10+
import ChatButton, {
11+
CHAT_BUTTON_KIND,
12+
CHAT_BUTTON_SIZE,
13+
} from "@carbon/ai-chat-components/es/react/chat-button.js";
14+
import React from "react";
15+
import { MountChildrenOnDelay } from "../components/util/MountChildrenOnDelay";
16+
17+
interface MessagesScrollToBottomButtonProps {
18+
ariaLabel: string;
19+
icon: React.ReactNode;
20+
onClick: () => void;
21+
}
22+
23+
function MessagesScrollToBottomButton({
24+
ariaLabel,
25+
icon,
26+
onClick,
27+
}: MessagesScrollToBottomButtonProps) {
28+
return (
29+
<MountChildrenOnDelay>
30+
<div className="cds-aichat__scroll-to-bottom">
31+
<ChatButton
32+
className="cds-aichat__scroll-to-bottom-button"
33+
size={CHAT_BUTTON_SIZE.SMALL}
34+
kind={CHAT_BUTTON_KIND.SECONDARY}
35+
aria-label={ariaLabel}
36+
onClick={onClick}
37+
>
38+
{icon}
39+
</ChatButton>
40+
</div>
41+
</MountChildrenOnDelay>
42+
);
43+
}
44+
45+
export { MessagesScrollToBottomButton };
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright IBM Corp. 2025
3+
*
4+
* This source code is licensed under the Apache-2.0 license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @license
8+
*/
9+
10+
import Processing from "@carbon/ai-chat-components/es/react/processing.js";
11+
import React from "react";
12+
import { AriaLiveMessage } from "../components/aria/AriaLiveMessage";
13+
import { CarbonTheme } from "../../types/config/PublicConfig";
14+
15+
interface MessagesTypingIndicatorProps {
16+
carbonTheme: CarbonTheme;
17+
index: number;
18+
isTypingMessage?: string;
19+
processingLabel: string;
20+
statusMessage?: string;
21+
}
22+
23+
function MessagesTypingIndicator({
24+
carbonTheme,
25+
index,
26+
isTypingMessage,
27+
processingLabel,
28+
statusMessage,
29+
}: MessagesTypingIndicatorProps) {
30+
return (
31+
<div
32+
className={`cds-aichat--message cds-aichat--message-${index} cds-aichat--message--last-message`}
33+
>
34+
<div className="cds-aichat--message--padding">
35+
{isTypingMessage && <AriaLiveMessage message={isTypingMessage} />}
36+
<div className="cds-aichat--assistant-message">
37+
<div className="cds-aichat--received cds-aichat--received--loading cds-aichat--message-vertical-padding">
38+
<div className="cds-aichat--received--inner">
39+
<div className="cds-aichat--processing">
40+
<Processing
41+
className="cds-aichat--processing-component"
42+
loop
43+
carbonTheme={carbonTheme}
44+
aria-label={processingLabel}
45+
/>{" "}
46+
<div className="cds-aichat--processing-label">
47+
{statusMessage}
48+
</div>
49+
</div>
50+
</div>
51+
</div>
52+
</div>
53+
</div>
54+
</div>
55+
);
56+
}
57+
58+
export { MessagesTypingIndicator };
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright IBM Corp. 2025
3+
*
4+
* This source code is licensed under the Apache-2.0 license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @license
8+
*/
9+
10+
import cx from "classnames";
11+
import React from "react";
12+
13+
interface MessagesViewProps {
14+
bottomScrollHandle: React.ReactNode;
15+
bottomSpacerRef: React.RefObject<HTMLDivElement>;
16+
humanAgentBanner: React.ReactNode;
17+
messagesContainerRef: React.RefObject<HTMLDivElement>;
18+
onScroll: () => void;
19+
regularMessages: React.ReactNode[];
20+
scrollDownButton?: React.ReactNode;
21+
scrollHandleHasFocus: boolean;
22+
topScrollHandle: React.ReactNode;
23+
typingIndicator?: React.ReactNode;
24+
}
25+
26+
function MessagesView({
27+
bottomScrollHandle,
28+
bottomSpacerRef,
29+
humanAgentBanner,
30+
messagesContainerRef,
31+
onScroll,
32+
regularMessages,
33+
scrollDownButton,
34+
scrollHandleHasFocus,
35+
topScrollHandle,
36+
typingIndicator,
37+
}: MessagesViewProps) {
38+
return (
39+
<div className="cds-aichat--messages--holder">
40+
{humanAgentBanner}
41+
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
42+
<div
43+
className={cx("cds-aichat--messages__wrapper", {
44+
"cds-aichat--messages__wrapper--scroll-handle-has-focus":
45+
scrollHandleHasFocus,
46+
})}
47+
ref={messagesContainerRef}
48+
onScroll={onScroll}
49+
>
50+
{topScrollHandle}
51+
<div className="cds-aichat--messages">
52+
{regularMessages}
53+
{typingIndicator}
54+
<div id="chat-bottom-spacer" ref={bottomSpacerRef} />
55+
{scrollDownButton}
56+
</div>
57+
{bottomScrollHandle}
58+
</div>
59+
</div>
60+
);
61+
}
62+
63+
export { MessagesView };

packages/ai-chat/src/chat/utils/domUtils.ts

Lines changed: 12 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -52,28 +52,26 @@ function doScrollElementIntoView(
5252
* @param element The scrollable element to set the scroll position of.
5353
* @param scrollTop The scrollTop value to set.
5454
* @param scrollLeft The scrollLeft value to set.
55-
* @param animate Indicates if the scrolling should be done using animation.
55+
* @param animate Indicates if the scrolling should be done using smooth scroll animation.
5656
*/
5757
function doScrollElement(
5858
element: Element,
5959
scrollTop: number,
6060
scrollLeft: number,
6161
animate = false,
6262
) {
63-
setTimeout(() => {
64-
if (element) {
65-
if (animate && element.scroll) {
66-
element.scroll({
67-
top: scrollTop,
68-
left: scrollLeft,
69-
behavior: "smooth",
70-
});
71-
} else {
72-
element.scrollTop = scrollTop;
73-
element.scrollLeft = scrollLeft;
74-
}
63+
if (element) {
64+
if (animate && element.scroll) {
65+
element.scroll({
66+
top: scrollTop,
67+
left: scrollLeft,
68+
behavior: "smooth",
69+
});
70+
} else {
71+
element.scrollTop = scrollTop;
72+
element.scrollLeft = scrollLeft;
7573
}
76-
});
74+
}
7775
}
7876

7977
/**
@@ -241,53 +239,6 @@ function getScrollBottom(element: HTMLElement) {
241239
return 0;
242240
}
243241

244-
/**
245-
* Continuously checks an element's scrollHeight on each animation frame and resolves
246-
* once the scrollHeight remains unchanged for a specified number of consecutive frames (default 2 frames).
247-
*
248-
* This is useful for waiting until layout changes (e.g., animations, content loading, dropdowns expanding/collapsing)
249-
* have fully settled before performing measurements or scroll adjustments.
250-
*
251-
* Uses scrollHeight instead of clientHeight/getBoundingClientRect to detect content changes inside scrollable containers.
252-
*/
253-
function waitForStableHeight(
254-
el: HTMLElement,
255-
opts: { frames?: number; timeoutMs?: number } = {},
256-
): Promise<void> {
257-
const requiredStableFrames = opts.frames ?? 2;
258-
const timeoutMs = opts.timeoutMs ?? 500;
259-
let stableFrames = 0;
260-
let lastHeight = el.scrollHeight;
261-
let rafId: number | null = null;
262-
263-
return new Promise((resolve) => {
264-
const deadline = performance.now() + timeoutMs;
265-
266-
const tick = () => {
267-
const now = performance.now();
268-
const h = el.scrollHeight;
269-
270-
if (h === lastHeight) {
271-
stableFrames += 1;
272-
} else {
273-
stableFrames = 0;
274-
lastHeight = h;
275-
}
276-
277-
if (stableFrames >= requiredStableFrames || now >= deadline) {
278-
if (rafId != null) {
279-
cancelAnimationFrame(rafId);
280-
}
281-
resolve();
282-
} else {
283-
rafId = requestAnimationFrame(tick);
284-
}
285-
};
286-
287-
rafId = requestAnimationFrame(tick);
288-
});
289-
}
290-
291242
export {
292243
SCROLLBAR_WIDTH,
293244
doScrollElement,
@@ -302,5 +253,4 @@ export {
302253
focusOnFirstFocusableElement,
303254
isEnterKey,
304255
getScrollBottom,
305-
waitForStableHeight,
306256
};

packages/ai-chat/src/chat/utils/messageUtils.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import { DeepPartial } from "../../types/utilities/DeepPartial";
1313
import { AppConfig } from "../../types/state/AppConfig";
1414
import { AppState } from "../../types/state/AppState";
1515
import { FileUpload } from "../../types/config/ServiceDeskConfig";
16-
import { LocalMessageItem } from "../../types/messaging/LocalMessageItem";
16+
import {
17+
LocalMessageItem,
18+
MessageErrorState,
19+
} from "../../types/messaging/LocalMessageItem";
1720
import { FileStatusValue } from "./constants";
1821
import { findLastWithMap } from "./lang/arrayUtils";
1922
import { uuid, UUIDType } from "./lang/uuid";
@@ -580,6 +583,39 @@ function isStandaloneSystemMessage(message: Message): boolean {
580583
return false;
581584
}
582585

586+
/**
587+
* Returns the ID of the last message response that should have interactive inputs enabled.
588+
*
589+
* As soon as the user sends a message, all previous responses are disabled to prevent re-interaction.
590+
* However, if the latest request resulted in an error, the last response is re-enabled so the user
591+
* isn't left stuck with a disabled input bar.
592+
*
593+
* @param localMessageItems - The ordered list of local message items
594+
* @param allMessagesByID - Map of all messages by their IDs
595+
*/
596+
function getMessageIDForUserInput(
597+
localMessageItems: LocalMessageItem[],
598+
allMessagesByID: Record<string, Message>,
599+
): string | null {
600+
for (let index = localMessageItems.length - 1; index >= 0; index--) {
601+
const message = localMessageItems[index];
602+
const originalMessage = allMessagesByID[message.fullMessageID];
603+
if (
604+
isRequest(originalMessage) &&
605+
originalMessage?.history?.error_state !== MessageErrorState.FAILED
606+
) {
607+
// If we find a request that was not an error, then we need to disable everything.
608+
return null;
609+
}
610+
if (isResponse(originalMessage)) {
611+
// If we didn't find a successful request, then the first response we find can be enabled.
612+
return message.fullMessageID;
613+
}
614+
}
615+
// Nothing should be enabled.
616+
return null;
617+
}
618+
583619
export {
584620
getOptionType,
585621
isResponse,
@@ -621,4 +657,5 @@ export {
621657
isFullWidthUserDefined,
622658
isSystemMessageItem,
623659
isStandaloneSystemMessage,
660+
getMessageIDForUserInput,
624661
};

0 commit comments

Comments
 (0)