Skip to content

Commit 0ba22e7

Browse files
authored
Group notices in workbench gui. (#642)
1 parent fd1e8ee commit 0ba22e7

File tree

2 files changed

+259
-77
lines changed

2 files changed

+259
-77
lines changed

workbench-app/src/components/Conversations/InteractHistory.tsx

Lines changed: 138 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ConversationMessage } from '../../models/ConversationMessage';
1616
import { ConversationParticipant } from '../../models/ConversationParticipant';
1717
import { useUpdateConversationParticipantMutation } from '../../services/workbench';
1818
import { MemoizedInteractMessage } from './Message/InteractMessage';
19+
import { MemoizedNotificationAccordion } from './Message/NotificationAccordion';
1920
import { MemoizedToolResultMessage } from './Message/ToolResultMessage';
2021
import { ParticipantStatus } from './ParticipantStatus';
2122

@@ -132,95 +133,155 @@ export const InteractHistory: React.FC<InteractHistoryProps> = (props) => {
132133

133134
// create a list of memoized interact message components for rendering in the virtuoso component
134135
React.useEffect(() => {
135-
let lastMessageInfo = {
136-
participantId: '',
137-
attribution: undefined as string | undefined,
138-
time: undefined as dayjs.Dayjs | undefined,
139-
};
140136
let lastDate = '';
141137
let generatedResponseCount = 0;
142138

143-
const updatedItems = messages
144-
.filter((message) => message.messageType !== 'log')
145-
.map((message, index) => {
146-
// if a hash is provided, check if the message id matches the hash
147-
if (hash === `#${message.id}`) {
148-
// set the hash item index to scroll to the item
149-
setScrollToIndex(index);
150-
}
139+
// Filter out log messages first
140+
const filteredMessages = messages.filter((message) => message.messageType !== 'log');
151141

152-
const senderParticipant = participants.find(
153-
(participant) => participant.id === message.sender.participantId,
154-
);
155-
if (!senderParticipant) {
156-
// if the sender participant is not found, do not render the message.
157-
// this can happen temporarily if the provided conversation was just
158-
// re-retrieved, but the participants have not been re-retrieved yet
159-
return (
160-
<div key={message.id} className={classes.item}>
161-
Participant not found: {message.sender.participantId} in conversation {conversation.id}
142+
// Group sequential notification messages
143+
const groupedItems: React.ReactNode[] = [];
144+
let currentNotificationGroup: ConversationMessage[] = [];
145+
let lastNotificationTime: dayjs.Dayjs | undefined;
146+
147+
const finishCurrentGroup = (insertIndex?: number) => {
148+
if (currentNotificationGroup.length > 0) {
149+
if (currentNotificationGroup.length === 1) {
150+
// Single notification - render normally
151+
const message = currentNotificationGroup[0];
152+
const item = renderSingleMessage(message, insertIndex);
153+
if (item) groupedItems.push(item);
154+
} else {
155+
// Multiple notifications - render as accordion
156+
const accordionItem = (
157+
<div className={classes.item} key={`accordion-${currentNotificationGroup[0].id}`}>
158+
<MemoizedNotificationAccordion
159+
messages={currentNotificationGroup}
160+
participants={participants}
161+
conversation={conversation}
162+
readOnly={readOnly}
163+
onRead={handleOnRead}
164+
onVisible={handleOnVisible}
165+
onRewindToBefore={onRewindToBefore}
166+
/>
162167
</div>
163168
);
169+
groupedItems.push(accordionItem);
164170
}
171+
currentNotificationGroup = [];
172+
}
173+
};
165174

166-
const date = Utility.toFormattedDateString(message.timestamp, 'M/D/YY');
167-
let displayDate = false;
168-
if (date !== lastDate) {
169-
displayDate = true;
170-
lastDate = date;
171-
}
172-
173-
if (
174-
message.messageType === 'chat' &&
175-
message.sender.participantRole !== 'user' &&
176-
message.metadata?.generated_content !== false
177-
) {
178-
generatedResponseCount += 1;
179-
}
180-
181-
// avoid duplicate header for messages from the same participant, if the
182-
// attribution is the same and the message is within a minute of the last
183-
let hideParticipant = message.messageType !== 'chat';
184-
const messageTime = dayjs.utc(message.timestamp);
185-
if (
186-
lastMessageInfo.participantId === senderParticipant.id &&
187-
lastMessageInfo.attribution === message.metadata?.attribution &&
188-
messageTime.diff(lastMessageInfo.time, 'minute') < 1
189-
) {
190-
hideParticipant = true;
191-
}
192-
lastMessageInfo = {
193-
participantId: senderParticipant.id,
194-
attribution: message.metadata?.attribution,
195-
time: messageTime,
196-
};
197-
198-
// FIXME: add new message type in workbench service/app for tool results
199-
const isToolResult = message.messageType === 'note' && message.metadata?.['tool_result'];
200-
201-
// Use memoized message components to prevent re-rendering all messages when one changes
202-
const messageContent = isToolResult ? (
203-
<MemoizedToolResultMessage conversation={conversation} message={message} readOnly={readOnly} />
204-
) : (
205-
<MemoizedInteractMessage
206-
readOnly={readOnly}
207-
conversation={conversation}
208-
message={message}
209-
participant={senderParticipant}
210-
hideParticipant={hideParticipant}
211-
displayDate={displayDate}
212-
onRead={handleOnRead}
213-
onVisible={handleOnVisible}
214-
onRewind={onRewindToBefore}
215-
/>
216-
);
175+
const renderSingleMessage = (message: ConversationMessage, originalIndex?: number) => {
176+
// if a hash is provided, check if the message id matches the hash
177+
if (hash === `#${message.id}`) {
178+
// set the hash item index to scroll to the item
179+
setScrollToIndex(originalIndex ?? groupedItems.length);
180+
}
217181

182+
const senderParticipant = participants.find(
183+
(participant) => participant.id === message.sender.participantId,
184+
);
185+
if (!senderParticipant) {
186+
// if the sender participant is not found, do not render the message.
187+
// this can happen temporarily if the provided conversation was just
188+
// re-retrieved, but the participants have not been re-retrieved yet
218189
return (
219-
<div className={classes.item} key={message.id}>
220-
{messageContent}
190+
<div key={message.id} className={classes.item}>
191+
Participant not found: {message.sender.participantId} in conversation {conversation.id}
221192
</div>
222193
);
223-
});
194+
}
195+
196+
const date = Utility.toFormattedDateString(message.timestamp, 'M/D/YY');
197+
let displayDate = false;
198+
if (date !== lastDate) {
199+
displayDate = true;
200+
lastDate = date;
201+
}
202+
203+
if (
204+
message.messageType === 'chat' &&
205+
message.sender.participantRole !== 'user' &&
206+
message.metadata?.generated_content !== false
207+
) {
208+
generatedResponseCount += 1;
209+
}
210+
211+
// FIXME: add new message type in workbench service/app for tool results
212+
const isToolResult = message.messageType === 'note' && message.metadata?.['tool_result'];
213+
214+
// Use memoized message components to prevent re-rendering all messages when one changes
215+
const messageContent = isToolResult ? (
216+
<MemoizedToolResultMessage conversation={conversation} message={message} readOnly={readOnly} />
217+
) : (
218+
<MemoizedInteractMessage
219+
readOnly={readOnly}
220+
conversation={conversation}
221+
message={message}
222+
participant={senderParticipant}
223+
hideParticipant={false} // Single messages show participant
224+
displayDate={displayDate}
225+
onRead={handleOnRead}
226+
onVisible={handleOnVisible}
227+
onRewind={onRewindToBefore}
228+
/>
229+
);
230+
231+
return (
232+
<div className={classes.item} key={message.id}>
233+
{messageContent}
234+
</div>
235+
);
236+
};
237+
238+
filteredMessages.forEach((message, index) => {
239+
const senderParticipant = participants.find(
240+
(participant) => participant.id === message.sender.participantId,
241+
);
242+
243+
// Skip if participant not found
244+
if (!senderParticipant) {
245+
finishCurrentGroup(index);
246+
const errorItem = renderSingleMessage(message, index);
247+
if (errorItem) groupedItems.push(errorItem);
248+
return;
249+
}
250+
251+
const isNotification = message.messageType !== 'chat';
252+
const messageTime = dayjs.utc(message.timestamp);
253+
254+
if (isNotification) {
255+
// Check if this notification should be grouped with the previous ones
256+
// Group all sequential notifications within 1 minute regardless of sender
257+
const shouldGroup = currentNotificationGroup.length > 0 &&
258+
lastNotificationTime &&
259+
messageTime.diff(lastNotificationTime, 'minute') < 1;
260+
261+
if (shouldGroup) {
262+
// Add to current group
263+
currentNotificationGroup.push(message);
264+
} else {
265+
// Finish current group and start new one
266+
finishCurrentGroup(index);
267+
currentNotificationGroup = [message];
268+
}
269+
270+
lastNotificationTime = messageTime;
271+
} else {
272+
// Chat message - finish any current notification group
273+
finishCurrentGroup(index);
274+
275+
// Render chat message normally
276+
const item = renderSingleMessage(message, index);
277+
if (item) groupedItems.push(item);
278+
}
279+
});
280+
281+
// Finish any remaining notification group
282+
finishCurrentGroup();
283+
284+
const updatedItems = groupedItems;
224285

225286
if (generatedResponseCount > 0) {
226287
updatedItems.push(
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
import {
4+
Accordion,
5+
AccordionHeader,
6+
AccordionItem,
7+
AccordionPanel,
8+
makeStyles,
9+
shorthands,
10+
tokens,
11+
} from '@fluentui/react-components';
12+
import { ChevronDownRegular } from '@fluentui/react-icons';
13+
import React from 'react';
14+
import { Conversation } from '../../../models/Conversation';
15+
import { ConversationMessage } from '../../../models/ConversationMessage';
16+
import { ConversationParticipant } from '../../../models/ConversationParticipant';
17+
import { MemoizedInteractMessage } from './InteractMessage';
18+
import { MemoizedToolResultMessage } from './ToolResultMessage';
19+
20+
const useClasses = makeStyles({
21+
root: {
22+
backgroundColor: tokens.colorNeutralBackground2,
23+
borderRadius: tokens.borderRadiusMedium,
24+
...shorthands.border('1px', 'solid', tokens.colorNeutralStroke2),
25+
...shorthands.margin(tokens.spacingVerticalXS, 0),
26+
},
27+
header: {
28+
...shorthands.padding(tokens.spacingVerticalS, tokens.spacingHorizontalM),
29+
fontSize: tokens.fontSizeBase200,
30+
color: tokens.colorNeutralForeground3,
31+
},
32+
panel: {
33+
...shorthands.padding(0, tokens.spacingHorizontalM, tokens.spacingVerticalS),
34+
},
35+
messageItem: {
36+
...shorthands.margin(tokens.spacingVerticalXS, 0),
37+
'&:last-child': {
38+
marginBottom: 0,
39+
},
40+
},
41+
});
42+
43+
interface NotificationAccordionProps {
44+
messages: ConversationMessage[];
45+
participants: ConversationParticipant[];
46+
conversation: Conversation;
47+
readOnly: boolean;
48+
onRead?: (message: ConversationMessage) => void;
49+
onVisible?: (message: ConversationMessage) => void;
50+
onRewindToBefore?: (message: ConversationMessage, redo: boolean) => Promise<void>;
51+
}
52+
53+
export const NotificationAccordion: React.FC<NotificationAccordionProps> = (props) => {
54+
const { messages, participants, conversation, readOnly, onRead, onVisible, onRewindToBefore } = props;
55+
const classes = useClasses();
56+
const [isOpen, setIsOpen] = React.useState(false);
57+
58+
if (messages.length === 0) return null;
59+
60+
// Create a summary for the accordion header
61+
const getSummary = () => {
62+
const count = messages.length;
63+
return `${count} notice${count === 1 ? '' : 's'}`;
64+
};
65+
66+
return (
67+
<div className={classes.root}>
68+
<Accordion collapsible>
69+
<AccordionItem value="notifications">
70+
<AccordionHeader
71+
className={classes.header}
72+
expandIcon={<ChevronDownRegular />}
73+
onClick={() => setIsOpen(!isOpen)}
74+
>
75+
<span>{getSummary()}</span>
76+
</AccordionHeader>
77+
<AccordionPanel className={classes.panel}>
78+
{messages.map((message) => {
79+
const messageParticipant = participants.find(
80+
p => p.id === message.sender.participantId,
81+
);
82+
83+
if (!messageParticipant) return null;
84+
85+
// Check if this is a tool result message
86+
const isToolResult = message.messageType === 'note' && message.metadata?.['tool_result'];
87+
88+
const messageContent = isToolResult ? (
89+
<MemoizedToolResultMessage
90+
conversation={conversation}
91+
message={message}
92+
readOnly={readOnly}
93+
/>
94+
) : (
95+
<MemoizedInteractMessage
96+
readOnly={readOnly}
97+
conversation={conversation}
98+
message={message}
99+
participant={messageParticipant}
100+
hideParticipant={true} // Always hide participant in accordion
101+
displayDate={false} // Don't show date for individual messages
102+
onRead={onRead}
103+
onVisible={onVisible}
104+
onRewind={onRewindToBefore}
105+
/>
106+
);
107+
108+
return (
109+
<div key={message.id} className={classes.messageItem}>
110+
{messageContent}
111+
</div>
112+
);
113+
})}
114+
</AccordionPanel>
115+
</AccordionItem>
116+
</Accordion>
117+
</div>
118+
);
119+
};
120+
121+
export const MemoizedNotificationAccordion = React.memo(NotificationAccordion);

0 commit comments

Comments
 (0)