Skip to content

Commit d0864ba

Browse files
authored
feat: Add context label to chat interface (#3939)
* feat: add context label to chat interface * feat: add context upon opening/refreshing * refactor: chat history into message groups per context * feat: sticky styling * test: adjust test to cover message context functionality
1 parent b950575 commit d0864ba

File tree

14 files changed

+591
-184
lines changed

14 files changed

+591
-184
lines changed

src/components/KymaCompanion/api/getChatResponse.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
11
import { getClusterConfig } from 'state/utils/getBackendInfo';
2-
import {
3-
ErrorType,
4-
ErrResponse,
5-
MessageChunk,
6-
} from '../components/Chat/Message/Message';
72
import { HttpError } from './error';
83
import {
94
handleChatErrorResponseFn,
105
handleChatResponseFn,
116
retryFetch,
127
} from 'components/KymaCompanion/api/retry';
8+
import { ErrorType, ErrResponse, MessageChunk } from '../components/Chat/types';
139

1410
const MAX_ATTEMPTS = 3;
1511
const RETRY_DELAY = 1_000;

src/components/KymaCompanion/api/retry.test.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,7 @@ import {
66
handleChatResponseFn,
77
retryFetch,
88
} from 'components/KymaCompanion/api/retry';
9-
import {
10-
ErrorType,
11-
ErrResponse,
12-
MessageChunk,
13-
} from 'components/KymaCompanion/components/Chat/Message/Message';
9+
import { ErrorType, ErrResponse, MessageChunk } from '../components/Chat/types';
1410

1511
const successCall = 'Success';
1612
const errorCall = 'Attempt Failed';

src/components/KymaCompanion/components/Chat/Chat.scss

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,35 @@
66
flex-direction: column;
77
gap: 8px;
88
overflow-y: auto;
9-
9+
position: relative;
1010
&::-webkit-scrollbar {
1111
display: none;
1212
}
1313

14-
.message-container.left-aligned {
15-
position: relative;
16-
align-self: flex-start;
17-
}
14+
.context-group {
15+
display: flex;
16+
flex-direction: column;
17+
gap: 8px;
1818

19-
.message-container.right-aligned {
20-
position: relative;
21-
align-self: flex-end;
19+
&:not(:first-child) {
20+
margin-top: 8px;
21+
}
22+
23+
.message-context {
24+
display: flex;
25+
flex-direction: column;
26+
gap: 8px;
27+
28+
.message-container.left-aligned {
29+
position: relative;
30+
align-self: flex-start;
31+
}
32+
33+
.message-container.right-aligned {
34+
position: relative;
35+
align-self: flex-end;
36+
}
37+
}
2238
}
2339
}
2440

src/components/KymaCompanion/components/Chat/Chat.tsx

Lines changed: 99 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
11
import { useTranslation } from 'react-i18next';
2-
import React, { useEffect, useRef, useState } from 'react';
2+
import React, { useCallback, useEffect, useRef, useState } from 'react';
33
import { useRecoilValue } from 'recoil';
44
import { FlexBox, Icon, Text, TextArea } from '@ui5/webcomponents-react';
5-
import Message, {
6-
ErrorType,
7-
ErrResponse,
8-
MessageChunk,
9-
} from './Message/Message';
5+
import Message from './Message/Message';
106
import Bubbles from './Bubbles/Bubbles';
117
import ErrorMessage from './ErrorMessage/ErrorMessage';
128
import { sessionIDState } from 'state/companion/sessionIDAtom';
@@ -16,25 +12,21 @@ import getFollowUpQuestions from 'components/KymaCompanion/api/getFollowUpQuesti
1612
import getChatResponse from 'components/KymaCompanion/api/getChatResponse';
1713
import { usePromptSuggestions } from 'components/KymaCompanion/hooks/usePromptSuggestions';
1814
import { AIError } from '../KymaCompanion';
15+
import ContextLabel from './ContextLabel/ContextLabel';
16+
import {
17+
Author,
18+
ChatGroup,
19+
ErrResponse,
20+
ErrorType,
21+
MessageChunk,
22+
Message as MessageType,
23+
chatGroupHelpers,
24+
} from './types';
1925
import './Chat.scss';
2026

21-
enum Author {
22-
USER = 'user',
23-
AI = 'ai',
24-
}
25-
26-
export interface MessageType {
27-
author: Author;
28-
messageChunks: MessageChunk[];
29-
isLoading: boolean;
30-
suggestions?: string[];
31-
suggestionsLoading?: boolean;
32-
hasError?: boolean | undefined;
33-
}
34-
3527
type ChatProps = {
36-
chatHistory: MessageType[];
37-
setChatHistory: React.Dispatch<React.SetStateAction<MessageType[]>>;
28+
chatHistory: ChatGroup[];
29+
setChatHistory: React.Dispatch<React.SetStateAction<ChatGroup[]>>;
3830
loading: boolean;
3931
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
4032
isReset: boolean;
@@ -66,62 +58,54 @@ export const Chat = ({
6658
initialSuggestionsLoading,
6759
currentResource,
6860
} = usePromptSuggestions(isReset, setIsReset, {
69-
skip: chatHistory.length > 1,
61+
skip:
62+
chatHistory.reduce((count, group) => count + group.messages.length, 0) >
63+
1,
7064
});
7165

72-
const addMessage = ({ author, messageChunks, isLoading }: MessageType) => {
73-
setChatHistory(prevItems =>
74-
prevItems.concat({ author, messageChunks, isLoading }),
66+
const getCurrentContext = useCallback(() => {
67+
if (!currentResource.resourceType) return undefined;
68+
return currentResource.resourceName
69+
? `${currentResource.resourceType} - ${currentResource.resourceName}`
70+
: currentResource.resourceType;
71+
}, [currentResource]);
72+
73+
const addMessage = (message: MessageType) => {
74+
const currentContext = getCurrentContext();
75+
setChatHistory(prevGroups =>
76+
chatGroupHelpers.addMessage(prevGroups, message, currentContext),
7577
);
7678
};
7779

7880
const updateLatestMessage = (updates: Partial<MessageType>) => {
79-
setChatHistory(prevMessages => {
80-
if (prevMessages.length === 0) return prevMessages;
81-
82-
const [latestMessage] = prevMessages.slice(-1);
83-
return prevMessages.slice(0, -1).concat({
84-
...latestMessage,
85-
...updates,
86-
});
87-
});
81+
setChatHistory(prevGroups =>
82+
chatGroupHelpers.updateLatestMessage(prevGroups, updates),
83+
);
8884
};
8985

9086
const concatMsgToLatestMessage = (
9187
response: MessageChunk,
9288
isLoading: boolean,
9389
) => {
94-
setChatHistory(prevMessages => {
95-
const [latestMessage] = prevMessages.slice(-1);
96-
return prevMessages.slice(0, -1).concat({
97-
...latestMessage,
98-
messageChunks: latestMessage.messageChunks.concat(response),
90+
setChatHistory(prevGroups =>
91+
chatGroupHelpers.concatMsgToLatestMessage(
92+
prevGroups,
93+
response,
9994
isLoading,
100-
});
101-
});
95+
),
96+
);
10297
};
10398

10499
const removeLastMessage = () => {
105-
setChatHistory(prevMessages => prevMessages.slice(0, -1));
100+
setChatHistory(prevGroups =>
101+
chatGroupHelpers.removeLastMessage(prevGroups),
102+
);
106103
};
107104

108105
const setErrorOnLastUserMsg = () => {
109-
setChatHistory(prevMessages => {
110-
const lastUserMsgIdx = prevMessages.findLastIndex(msg => {
111-
return msg.author === 'user';
112-
});
113-
if (lastUserMsgIdx === -1) {
114-
return prevMessages;
115-
}
116-
117-
return prevMessages.map((msg, idx) => {
118-
if (idx === lastUserMsgIdx) {
119-
msg.hasError = true;
120-
msg.isLoading = false;
121-
}
122-
return msg;
123-
});
124-
});
106+
setChatHistory(prevGroups =>
107+
chatGroupHelpers.setErrorOnLastUserMsg(prevGroups),
108+
);
125109
};
126110

127111
const handleChatResponse = (response: MessageChunk) => {
@@ -220,11 +204,7 @@ export const Chat = ({
220204
};
221205

222206
const retryPreviousPrompt = () => {
223-
const lastUserMsgIdx = chatHistory.findLastIndex(
224-
v => v.author === Author.USER,
225-
);
226-
const previousPrompt = chatHistory.at(lastUserMsgIdx)?.messageChunks[0].data
227-
.answer.content;
207+
const previousPrompt = chatGroupHelpers.findLastUserPrompt(chatHistory);
228208
if (previousPrompt) {
229209
removeLastMessage();
230210
sendPrompt(previousPrompt);
@@ -234,6 +214,7 @@ export const Chat = ({
234214
const sendPrompt = (query: string) => {
235215
setError({ message: null, displayRetry: false });
236216
setLoading(true);
217+
237218
addMessage({
238219
author: Author.USER,
239220
messageChunks: [
@@ -285,8 +266,18 @@ export const Chat = ({
285266
};
286267

287268
useEffect(() => {
288-
if (chatHistory.length === 1) {
269+
const totalMessageCount = chatHistory.reduce(
270+
(count, group) => count + group.messages.length,
271+
0,
272+
);
273+
274+
if (totalMessageCount === 1) {
289275
if (initialSuggestionsLoading) {
276+
// Update the context of the first group
277+
const currentContext = getCurrentContext();
278+
setChatHistory(prevGroups =>
279+
chatGroupHelpers.updateFirstGroupContext(prevGroups, currentContext),
280+
);
290281
updateLatestMessage({
291282
messageChunks: [
292283
{
@@ -335,37 +326,52 @@ export const Chat = ({
335326
className="chat-container"
336327
>
337328
<div
338-
className="chat-list sap-margin-x-tiny sap-margin-top-small"
329+
className="chat-list sap-margin-x-tiny sap-margin-top-tiny"
339330
ref={containerRef}
340331
>
341-
{chatHistory.map((message, index) => {
342-
const isLast = index === chatHistory.length - 1;
343-
return message.author === Author.AI ? (
344-
<React.Fragment key={index}>
345-
<Message
346-
author={message.author}
347-
messageChunks={message.messageChunks}
348-
isLoading={message.isLoading}
349-
hasError={message?.hasError ?? false}
350-
isLatestMessage={isLast}
351-
/>
352-
{isLast && !message.isLoading && (
353-
<Bubbles
354-
onClick={sendPrompt}
355-
suggestions={message.suggestions}
356-
isLoading={message.suggestionsLoading ?? false}
357-
/>
332+
{chatHistory.map((group, groupIndex) => {
333+
const isLastGroup = groupIndex === chatHistory.length - 1;
334+
335+
return (
336+
<div key={groupIndex} className="context-group">
337+
{group.context && (
338+
<ContextLabel labelText={group.context.labelText} />
358339
)}
359-
</React.Fragment>
360-
) : (
361-
<Message
362-
author={Author.USER}
363-
key={index}
364-
messageChunks={message.messageChunks}
365-
isLoading={message.isLoading}
366-
hasError={message?.hasError ?? false}
367-
isLatestMessage={isLast}
368-
/>
340+
<div className="message-context">
341+
{group.messages.map((message, messageIndex) => {
342+
const isLastMessage =
343+
isLastGroup && messageIndex === group.messages.length - 1;
344+
345+
return message.author === Author.AI ? (
346+
<React.Fragment key={`${groupIndex}-${messageIndex}`}>
347+
<Message
348+
author={message.author}
349+
messageChunks={message.messageChunks}
350+
isLoading={message.isLoading}
351+
hasError={message.hasError ?? false}
352+
isLatestMessage={isLastMessage}
353+
/>
354+
{isLastMessage && !message.isLoading && (
355+
<Bubbles
356+
onClick={sendPrompt}
357+
suggestions={message.suggestions}
358+
isLoading={message.suggestionsLoading ?? false}
359+
/>
360+
)}
361+
</React.Fragment>
362+
) : (
363+
<Message
364+
author={Author.USER}
365+
key={`${groupIndex}-${messageIndex}`}
366+
messageChunks={message.messageChunks}
367+
isLoading={message.isLoading}
368+
hasError={message.hasError ?? false}
369+
isLatestMessage={isLastMessage}
370+
/>
371+
);
372+
})}
373+
</div>
374+
</div>
369375
);
370376
})}
371377
{error.message && (
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
.context-label {
2+
position: sticky;
3+
top: 0;
4+
z-index: 5;
5+
display: flex;
6+
align-items: center;
7+
padding: 0.5rem 0;
8+
margin-inline: 0.5rem;
9+
width: calc(100% - 1rem);
10+
background: var(--sapBaseColor);
11+
12+
&::before,
13+
&::after {
14+
content: '';
15+
flex-grow: 1;
16+
height: 1.25px;
17+
background-color: var(--sapNeutralColor);
18+
}
19+
}
20+
21+
.context-label-text {
22+
padding: 0 1rem;
23+
color: var(--sapNeutralColor);
24+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Text } from '@ui5/webcomponents-react';
2+
import './ContextLabel.scss';
3+
4+
interface ContextLabelProps {
5+
labelText: string;
6+
}
7+
8+
export default function ContextLabel({ labelText }: ContextLabelProps) {
9+
return (
10+
<div className="context-label">
11+
<Text className="context-label-text">{labelText}</Text>
12+
</div>
13+
);
14+
}

0 commit comments

Comments
 (0)