Skip to content

Commit 0347a49

Browse files
committed
Improve chat animations and auto-scroll
1 parent bf3399b commit 0347a49

File tree

12 files changed

+215
-167
lines changed

12 files changed

+215
-167
lines changed
Lines changed: 57 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo, useRef, useState } from "react";
1+
import { useEffect, useMemo, useState } from "react";
22
import { useParams } from "react-router";
33
import { Fragment } from "react/jsx-runtime";
44
import { useStableSearchParams } from "../../../../hooks/useStableSearchParams";
@@ -7,12 +7,11 @@ import {
77
useGetIncidentAgentsQuery
88
} from "../../../../redux/services/digma";
99
import type { GetIncidentAgentEventsResponse } from "../../../../redux/services/types";
10-
import { isBoolean } from "../../../../typeGuards/isBoolean";
1110
import { isNumber } from "../../../../typeGuards/isNumber";
12-
import { isUndefined } from "../../../../typeGuards/isUndefined";
1311
import { ThreeCirclesSpinner } from "../../../common/ThreeCirclesSpinner";
1412
import { Spinner } from "../../../common/v3/Spinner";
1513
import { TypingMarkdown } from "../TypingMarkdown";
14+
import { useAutoScroll } from "../useAutoScroll";
1615
import { convertToMarkdown } from "../utils/convertToMarkdown";
1716
import { Accordion } from "./Accordion";
1817
import * as s from "./styles";
@@ -25,12 +24,9 @@ export const AgentEvents = () => {
2524
const incidentId = params.id;
2625
const [searchParams] = useStableSearchParams();
2726
const agentId = searchParams.get("agent");
28-
const [initialAgentRunning, setInitialAgentRunning] = useState<boolean>();
27+
const [initialEventsCount, setInitialEventsCount] = useState<number>();
2928
const [eventsVisibleCount, setEventsVisibleCount] = useState<number>();
30-
const [data, setData] = useState<GetIncidentAgentEventsResponse>();
31-
const containerRef = useRef<HTMLDivElement | null>(null);
32-
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
33-
const scrollHeightRef = useRef<number>(0);
29+
const { elementRef, handleElementScroll } = useAutoScroll<HTMLDivElement>();
3430

3531
const { data: agentsData } = useGetIncidentAgentsQuery(
3632
{ id: incidentId ?? "" },
@@ -44,12 +40,12 @@ export const AgentEvents = () => {
4440
{ incidentId: incidentId ?? "", agentId: agentId ?? "" },
4541
{
4642
pollingInterval: REFRESH_INTERVAL,
47-
skip: !incidentId || !agentId || !isBoolean(initialAgentRunning)
43+
skip: !incidentId || !agentId
4844
}
4945
);
5046

5147
const handleMarkdownTypingComplete = (i: number) => () => {
52-
const events = data ?? [];
48+
const events = agentEventsData ?? [];
5349
const tokenEventsIndexes = events.reduce((acc, event, index) => {
5450
if (event.type === "token") {
5551
acc.push(index);
@@ -66,129 +62,73 @@ export const AgentEvents = () => {
6662
}
6763
};
6864

69-
const handleContainerScroll = () => {
70-
const isAtBottom = () => {
71-
if (!containerRef.current) {
72-
return false;
73-
}
74-
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
75-
return scrollHeight - scrollTop <= clientHeight + 1; // Allow a small buffer for precision issues
76-
};
77-
78-
if (!containerRef.current) {
79-
return;
80-
}
81-
82-
setShouldAutoScroll(isAtBottom());
83-
};
84-
85-
const scrollToBottom = () => {
86-
if (containerRef.current) {
87-
containerRef.current.scrollTop = containerRef.current.scrollHeight;
88-
}
89-
};
90-
91-
// Handle scroll height changes and auto-scroll
9265
useEffect(() => {
93-
const element = containerRef.current;
94-
if (!element) {
95-
return;
66+
if (agentEventsData) {
67+
setInitialEventsCount((prev) =>
68+
!isNumber(prev) ? agentEventsData.length : prev
69+
);
70+
setEventsVisibleCount(agentEventsData.length);
9671
}
97-
98-
const checkScrollHeight = () => {
99-
const currentScrollHeight = element.scrollHeight;
100-
101-
// Only auto-scroll if height has grown and auto-scroll is enabled
102-
if (currentScrollHeight > scrollHeightRef.current && shouldAutoScroll) {
103-
scrollToBottom();
104-
}
105-
106-
scrollHeightRef.current = currentScrollHeight;
107-
};
108-
109-
const mutationObserver = new MutationObserver(() => {
110-
// Use RAF to ensure DOM is updated before measuring
111-
requestAnimationFrame(checkScrollHeight);
112-
});
113-
114-
mutationObserver.observe(element, {
115-
childList: true,
116-
subtree: true,
117-
attributes: true,
118-
characterData: true
119-
});
120-
121-
// Initial setup
122-
scrollHeightRef.current = element.scrollHeight;
123-
scrollToBottom();
124-
125-
return () => {
126-
mutationObserver.disconnect();
127-
};
128-
}, [shouldAutoScroll]);
129-
130-
useEffect(() => {
131-
setData(agentEventsData);
13272
}, [agentEventsData]);
13373

134-
// Set agent initial running state
135-
useEffect(() => {
136-
if (!isBoolean(initialAgentRunning)) {
137-
const agent = agentsData?.agents.find((x) => x.name === agentId);
138-
setInitialAgentRunning(agent?.running);
139-
}
140-
}, [agentsData, agentId, initialAgentRunning]);
141-
142-
// Set initial visible count based on agent initial running state
143-
useEffect(() => {
144-
if (
145-
isBoolean(initialAgentRunning) &&
146-
isUndefined(eventsVisibleCount) &&
147-
data
148-
) {
149-
const initialCount = initialAgentRunning ? 1 : data.length;
150-
setEventsVisibleCount(initialCount);
151-
}
152-
}, [initialAgentRunning, eventsVisibleCount, data]);
153-
15474
const visibleEvents = useMemo(
15575
() =>
156-
data && isNumber(eventsVisibleCount)
157-
? data.slice(0, eventsVisibleCount)
76+
agentEventsData && isNumber(eventsVisibleCount)
77+
? agentEventsData.slice(0, eventsVisibleCount)
15878
: [],
159-
[data, eventsVisibleCount]
79+
[agentEventsData, eventsVisibleCount]
80+
);
81+
82+
const isAgentRunning = useMemo(
83+
() => Boolean(agentsData?.agents.find((x) => x.name === agentId)?.running),
84+
[agentsData, agentId]
16085
);
16186

87+
const shouldShowTypingForEvent = (index: number) =>
88+
isNumber(initialEventsCount) && index >= initialEventsCount;
89+
90+
const renderEvent = (
91+
event: GetIncidentAgentEventsResponse[number],
92+
i: number
93+
) => {
94+
switch (event.type) {
95+
case "token":
96+
return (
97+
<TypingMarkdown
98+
text={event.message}
99+
onComplete={
100+
shouldShowTypingForEvent(i)
101+
? handleMarkdownTypingComplete(i)
102+
: undefined
103+
}
104+
speed={shouldShowTypingForEvent(i) ? TYPING_SPEED : undefined}
105+
/>
106+
);
107+
case "tool":
108+
return (
109+
<Accordion
110+
summary={`${event.tool_name} (${[event.mcp_name, "MCP tool"]
111+
.filter(Boolean)
112+
.join(" ")})`}
113+
content={<TypingMarkdown text={convertToMarkdown(event.message)} />}
114+
/>
115+
);
116+
default:
117+
return null;
118+
}
119+
};
120+
162121
return (
163-
<s.Container ref={containerRef} onScroll={handleContainerScroll}>
164-
{!data && isLoading && (
122+
<s.Container ref={elementRef} onScroll={handleElementScroll}>
123+
{!agentEventsData && isLoading && (
165124
<s.LoadingContainer>
166125
<Spinner size={32} />
167126
</s.LoadingContainer>
168127
)}
169128
{visibleEvents.map((x, i) => (
170-
<Fragment key={i}>
171-
{x.type === "tool" ? (
172-
<Accordion
173-
summary={`${x.tool_name} (${[x.mcp_name, "MCP tool"]
174-
.filter(Boolean)
175-
.join(" ")})`}
176-
content={<TypingMarkdown text={convertToMarkdown(x.message)} />}
177-
/>
178-
) : (
179-
<TypingMarkdown
180-
text={x.message}
181-
onComplete={
182-
initialAgentRunning
183-
? handleMarkdownTypingComplete(i)
184-
: undefined
185-
}
186-
speed={initialAgentRunning ? TYPING_SPEED : undefined}
187-
/>
188-
)}
189-
</Fragment>
129+
<Fragment key={i}>{renderEvent(x, i)}</Fragment>
190130
))}
191-
{initialAgentRunning && <ThreeCirclesSpinner />}
131+
{isAgentRunning && <ThreeCirclesSpinner />}
192132
</s.Container>
193133
);
194134
};

src/components/Agentic/IncidentDetails/Chat/PromptInput/styles.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const Form = styled.form<FormProps>`
1616
position: relative;
1717
height: ${({ $height }) => $height}px;
1818
box-sizing: border-box;
19+
flex-shrink: 0;
1920
`;
2021

2122
export const TextArea = styled.textarea<TextAreaProps>`

src/components/Agentic/IncidentDetails/Chat/index.tsx

Lines changed: 74 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,45 @@
1-
import { Fragment, useEffect, useState } from "react";
1+
import { Fragment, useEffect, useMemo, useState } from "react";
22
import { useParams } from "react-router";
33
import { useStableSearchParams } from "../../../../hooks/useStableSearchParams";
44
import {
55
useGetIncidentAgentChatEventsQuery,
66
useSendMessageToIncidentAgentChatMutation
77
} from "../../../../redux/services/digma";
88
import type { IncidentAgentChatEvent } from "../../../../redux/services/types";
9+
import { isNumber } from "../../../../typeGuards/isNumber";
10+
import { isString } from "../../../../typeGuards/isString";
911
import { ThreeCirclesSpinner } from "../../../common/ThreeCirclesSpinner";
1012
import { Spinner } from "../../../common/v3/Spinner";
1113
import { Accordion } from "../AgentEvents/Accordion";
1214
import { TypingMarkdown } from "../TypingMarkdown";
15+
import { useAutoScroll } from "../useAutoScroll";
1316
import { convertToMarkdown } from "../utils/convertToMarkdown";
1417
import { PromptInput } from "./PromptInput";
1518
import * as s from "./styles";
1619

20+
const REFRESH_INTERVAL = 10 * 1000; // in milliseconds
21+
const TYPING_SPEED = 3; // in milliseconds per character
22+
1723
export const Chat = () => {
1824
const [inputValue, setInputValue] = useState("");
1925
const params = useParams();
2026
const incidentId = params.id;
2127
const [searchParams] = useStableSearchParams();
2228
const agentId = searchParams.get("agent");
23-
const [sendMessageToBeSent, setSendMessageToBeSent] = useState<string>();
29+
const [lastSentMessage, setLastSentMessage] = useState<string>();
30+
const { elementRef, handleElementScroll, scrollToBottom } =
31+
useAutoScroll<HTMLDivElement>();
32+
const [initialEventsCount, setInitialEventsCount] = useState<number>();
33+
const [eventsVisibleCount, setEventsVisibleCount] = useState<number>();
2434

2535
const { data, isLoading } = useGetIncidentAgentChatEventsQuery(
2636
{
2737
incidentId: incidentId ?? "",
2838
agentId: agentId ?? ""
2939
},
3040
{
31-
skip: !incidentId || !agentId
41+
skip: !incidentId || !agentId! || isString(lastSentMessage),
42+
pollingInterval: REFRESH_INTERVAL
3243
}
3344
);
3445

@@ -37,51 +48,97 @@ export const Chat = () => {
3748

3849
const handleInputSubmit = () => {
3950
setInputValue("");
40-
setSendMessageToBeSent(inputValue);
51+
setLastSentMessage(inputValue);
52+
scrollToBottom();
53+
4154
void sendMessage({
4255
incidentId: incidentId ?? "",
4356
agentId: agentId ?? "",
4457
data: { text: inputValue }
4558
}).finally(() => {
46-
setSendMessageToBeSent(undefined);
59+
setLastSentMessage(undefined);
4760
});
4861
};
4962

63+
const handleMarkdownTypingComplete = (i: number) => () => {
64+
const events = data ?? [];
65+
const aiEventsIndexes = events.reduce((acc, event, index) => {
66+
if (event.type === "ai") {
67+
acc.push(index);
68+
}
69+
return acc;
70+
}, [] as number[]);
71+
72+
const nextAiEventIndex = aiEventsIndexes.find((el) => el > i);
73+
74+
if (isNumber(nextAiEventIndex) && nextAiEventIndex >= 0) {
75+
setEventsVisibleCount(nextAiEventIndex + 1);
76+
} else {
77+
setEventsVisibleCount(events.length);
78+
}
79+
};
80+
5081
useEffect(() => {
51-
setInputValue("");
52-
}, [agentId]);
82+
if (data) {
83+
setInitialEventsCount((prev) => (!isNumber(prev) ? data.length : prev));
84+
setEventsVisibleCount(data.length);
85+
}
86+
}, [data]);
5387

54-
const renderChatEvent = (event: IncidentAgentChatEvent) => {
88+
const visibleEvents = useMemo(
89+
() =>
90+
data && isNumber(eventsVisibleCount)
91+
? data.slice(0, eventsVisibleCount)
92+
: [],
93+
[data, eventsVisibleCount]
94+
);
95+
96+
const shouldShowTypingForEvent = (index: number) =>
97+
Boolean(initialEventsCount && index >= initialEventsCount);
98+
99+
const renderChatEvent = (event: IncidentAgentChatEvent, i: number) => {
55100
switch (event.type) {
101+
case "ai":
102+
return (
103+
<TypingMarkdown
104+
text={event.message}
105+
onComplete={
106+
shouldShowTypingForEvent(i)
107+
? handleMarkdownTypingComplete(i)
108+
: undefined
109+
}
110+
speed={shouldShowTypingForEvent(i) ? TYPING_SPEED : undefined}
111+
/>
112+
);
56113
case "tool":
57114
return (
58115
<Accordion
59-
summary={"MCP tool"}
116+
summary={`${event.tool_name} (${[event.mcp_name, "MCP tool"]
117+
.filter(Boolean)
118+
.join(" ")})`}
60119
content={<TypingMarkdown text={convertToMarkdown(event.message)} />}
61120
/>
62121
);
63122
case "human":
64123
return <s.HumanMessage>{event.message}</s.HumanMessage>;
65-
case "ai":
124+
66125
default:
67-
return <TypingMarkdown text={event.message} />;
126+
return null;
68127
}
69128
};
70129

71130
return (
72131
<s.Container>
73-
<s.ChatHistory>
132+
<s.ChatHistory ref={elementRef} onScroll={handleElementScroll}>
74133
{!data && isLoading && (
75134
<s.LoadingContainer>
76135
<Spinner size={32} />
77136
</s.LoadingContainer>
78137
)}
79-
{data?.map((x, i) => (
80-
<Fragment key={i}>{renderChatEvent(x)}</Fragment>
138+
{visibleEvents?.map((x, i) => (
139+
<Fragment key={i}>{renderChatEvent(x, i)}</Fragment>
81140
))}
82-
{sendMessageToBeSent && (
83-
<s.HumanMessage>{sendMessageToBeSent}</s.HumanMessage>
84-
)}
141+
{lastSentMessage && <s.HumanMessage>{lastSentMessage}</s.HumanMessage>}
85142
{isMessageSending && <ThreeCirclesSpinner />}
86143
</s.ChatHistory>
87144
<PromptInput

0 commit comments

Comments
 (0)