Skip to content

Commit 34cfd15

Browse files
authored
Group messages to reduce visual noise (#179)
1 parent 9edcee5 commit 34cfd15

File tree

1 file changed

+168
-88
lines changed

1 file changed

+168
-88
lines changed

apps/web/src/app/(authenticated)/usage/Messages.tsx

Lines changed: 168 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,44 @@ const parseQuestionData = (text: string): QuestionData | null => {
4444
return null;
4545
};
4646

47+
type DecoratedMessage = Omit<Message, 'timestamp'> & {
48+
role: 'user' | 'assistant';
49+
name: string;
50+
timestamp: string;
51+
showHeader?: boolean;
52+
};
53+
54+
// Determine if a message should show its header based on grouping rules
55+
const shouldShowHeader = (
56+
message: DecoratedMessage,
57+
index: number,
58+
messages: DecoratedMessage[],
59+
groupingWindowMinutes: number,
60+
): boolean => {
61+
// Always show header for first message or user messages
62+
if (index === 0 || message.role === 'user') return true;
63+
64+
const prevMessage = messages[index - 1];
65+
if (!prevMessage) return true; // Safety check
66+
67+
// Show header if previous message was from user
68+
if (prevMessage.role === 'user') return true;
69+
70+
// Show header if mode changed
71+
if (message.mode !== prevMessage.mode) return true;
72+
73+
// Show header if time gap between consecutive messages exceeds threshold
74+
// Use the original timestamp (number) for calculation
75+
const currentTime = message.ts;
76+
const prevTime = prevMessage.ts;
77+
const gapMinutes = (currentTime - prevTime) / (1000 * 60);
78+
79+
return gapMinutes > groupingWindowMinutes;
80+
};
81+
82+
// Constant for message grouping window (in minutes)
83+
const GROUPING_WINDOW_MINUTES = 5;
84+
4785
export const Messages = ({
4886
messages,
4987
enableMessageLinks = false,
@@ -74,9 +112,25 @@ export const Messages = ({
74112
);
75113
});
76114

77-
return deduplicatedMessages.map((message, index) =>
115+
// Decorate messages with role, name, and timestamp
116+
const decoratedMessages = deduplicatedMessages.map((message, index) =>
78117
decorate({ message, index }),
79118
);
119+
120+
// Add grouping information to each message
121+
return decoratedMessages.map((message, index) => {
122+
const showHeader = shouldShowHeader(
123+
message,
124+
index,
125+
decoratedMessages,
126+
GROUPING_WINDOW_MINUTES,
127+
);
128+
129+
return {
130+
...message,
131+
showHeader,
132+
};
133+
});
80134
}, [messages]);
81135

82136
// Handle anchor link clicks
@@ -142,50 +196,75 @@ export const Messages = ({
142196
{/* Scrollable messages container */}
143197
<div
144198
ref={containerRef}
145-
className="space-y-6 pr-2 overflow-y-auto"
199+
className="pr-2 overflow-y-auto"
146200
style={{
147201
scrollbarWidth: 'thin',
148202
scrollbarColor: 'hsl(var(--border)) transparent',
149203
}}
150204
>
151-
{conversation.map((message) => {
152-
const isQuestion =
153-
message.type === 'ask' && message.ask === 'followup';
154-
const isCommand = message.type === 'ask' && message.ask === 'command';
155-
const questionData =
156-
isQuestion && message.text ? parseQuestionData(message.text) : null;
157-
158-
const messageId = `message-${message.id}`;
159-
160-
return (
161-
<div
162-
key={message.id}
163-
id={messageId}
164-
className={cn(
165-
'flex flex-col gap-3 rounded-lg p-4 relative transition-all duration-200',
166-
message.role === 'user' ? 'bg-primary/10' : 'bg-secondary/10',
167-
enableMessageLinks && 'hover:shadow-sm hover:bg-opacity-80',
168-
)}
169-
onMouseEnter={() =>
170-
enableMessageLinks && setHoveredMessageId(messageId)
171-
}
172-
onMouseLeave={() =>
173-
enableMessageLinks && setHoveredMessageId(null)
174-
}
175-
>
176-
<div className="flex flex-row items-center justify-between gap-2 text-xs font-medium text-muted-foreground">
177-
<div className="flex items-center gap-2">
178-
<div>{message.name}</div>
179-
<div>&middot;</div>
180-
<div>{message.timestamp}</div>
181-
</div>
182-
<div className="flex items-center gap-2">
183-
{/* Anchor Link Button */}
184-
{enableMessageLinks && hoveredMessageId === messageId && (
205+
<div className="space-y-1">
206+
{conversation.map((message, index) => {
207+
const isQuestion =
208+
message.type === 'ask' && message.ask === 'followup';
209+
const isCommand =
210+
message.type === 'ask' && message.ask === 'command';
211+
const questionData =
212+
isQuestion && message.text
213+
? parseQuestionData(message.text)
214+
: null;
215+
216+
const messageId = `message-${message.id}`;
217+
218+
return (
219+
<div
220+
key={message.id}
221+
id={messageId}
222+
className={cn(
223+
'flex flex-col relative transition-all duration-200 gap-3 rounded-lg p-4',
224+
message.role === 'user'
225+
? 'bg-primary/15 border border-primary/20 shadow-sm'
226+
: 'bg-secondary/10',
227+
enableMessageLinks && 'hover:shadow-sm hover:bg-opacity-80',
228+
// Add extra top margin for messages that start a new group
229+
message.showHeader && index > 0 && 'mt-4',
230+
)}
231+
onMouseEnter={() =>
232+
enableMessageLinks && setHoveredMessageId(messageId)
233+
}
234+
onMouseLeave={() =>
235+
enableMessageLinks && setHoveredMessageId(null)
236+
}
237+
>
238+
{message.showHeader && (
239+
<div
240+
className={cn(
241+
'flex flex-row items-center justify-between gap-2 text-xs font-medium',
242+
message.role === 'user'
243+
? 'text-primary font-semibold'
244+
: 'text-muted-foreground',
245+
)}
246+
>
247+
<div className="flex items-center gap-2">
248+
<div>{message.name}</div>
249+
<div>&middot;</div>
250+
<div>{message.timestamp}</div>
251+
{message.mode && (
252+
<>
253+
<div>&middot;</div>
254+
<div>{message.mode}</div>
255+
</>
256+
)}
257+
</div>
258+
</div>
259+
)}
260+
261+
{/* Anchor Link Button - shown on hover for all messages */}
262+
{enableMessageLinks && hoveredMessageId === messageId && (
263+
<div className="absolute top-2 right-2 z-10">
185264
<button
186265
onClick={() => handleAnchorClick(messageId)}
187266
className={cn(
188-
'p-1 rounded hover:bg-muted transition-colors duration-200 cursor-pointer',
267+
'p-1 rounded bg-background/90 backdrop-blur-sm border border-border/50 shadow-sm hover:bg-muted transition-colors duration-200 cursor-pointer',
189268
clickedMessageId === messageId && 'bg-primary/10',
190269
)}
191270
title="Copy link to this message"
@@ -198,59 +277,54 @@ export const Messages = ({
198277
)}
199278
/>
200279
</button>
201-
)}
202-
{message.mode && (
203-
<div className="px-2 py-1 bg-muted rounded text-xs font-medium">
204-
{message.mode}
205-
</div>
206-
)}
207-
</div>
208-
</div>
280+
</div>
281+
)}
209282

210-
{isQuestion && questionData ? (
211-
<div className="space-y-4">
212-
{questionData.question && (
213-
<div className="text-sm leading-relaxed">
214-
{questionData.question}
215-
</div>
216-
)}
217-
{questionData.suggestions &&
218-
questionData.suggestions.length > 0 && (
219-
<div className="space-y-2">
220-
{questionData.suggestions.map((suggestion, index) => (
221-
<div
222-
key={index}
223-
className="px-4 py-3 bg-background border border-border rounded-md text-sm hover:bg-muted/50 cursor-pointer transition-colors"
224-
>
225-
{typeof suggestion === 'string'
226-
? suggestion
227-
: suggestion.answer}
228-
</div>
229-
))}
283+
{isQuestion && questionData ? (
284+
<div className="space-y-4">
285+
{questionData.question && (
286+
<div className="text-sm leading-relaxed">
287+
{questionData.question}
230288
</div>
231289
)}
232-
</div>
233-
) : isCommand ? (
234-
<div className="space-y-3">
235-
<div className="bg-black/90 text-foreground p-3 rounded-md font-mono text-sm">
236-
{message.text}
290+
{questionData.suggestions &&
291+
questionData.suggestions.length > 0 && (
292+
<div className="space-y-2">
293+
{questionData.suggestions.map((suggestion, index) => (
294+
<div
295+
key={index}
296+
className="px-4 py-3 bg-background border border-border rounded-md text-sm hover:bg-muted/50 cursor-pointer transition-colors"
297+
>
298+
{typeof suggestion === 'string'
299+
? suggestion
300+
: suggestion.answer}
301+
</div>
302+
))}
303+
</div>
304+
)}
237305
</div>
238-
</div>
239-
) : (
240-
<div className="text-sm leading-relaxed markdown-prose">
241-
<ReactMarkdown
242-
components={{
243-
a: PlainTextLink,
244-
code: CodeBlock,
245-
}}
246-
>
247-
{message.text}
248-
</ReactMarkdown>
249-
</div>
250-
)}
251-
</div>
252-
);
253-
})}
306+
) : isCommand ? (
307+
<div className="space-y-3">
308+
<div className="bg-black/90 text-foreground p-3 rounded-md font-mono text-sm">
309+
{message.text}
310+
</div>
311+
</div>
312+
) : (
313+
<div className="text-sm leading-relaxed markdown-prose">
314+
<ReactMarkdown
315+
components={{
316+
a: PlainTextLink,
317+
code: CodeBlock,
318+
}}
319+
>
320+
{message.text}
321+
</ReactMarkdown>
322+
</div>
323+
)}
324+
</div>
325+
);
326+
})}
327+
</div>
254328
</div>
255329

256330
{/* Scroll to bottom button - shown when user has scrolled up */}
@@ -282,8 +356,14 @@ export const Messages = ({
282356
);
283357
};
284358

285-
const decorate = ({ message, index }: { message: Message; index: number }) => {
286-
const role =
359+
const decorate = ({
360+
message,
361+
index,
362+
}: {
363+
message: Message;
364+
index: number;
365+
}): DecoratedMessage => {
366+
const role: 'user' | 'assistant' =
287367
index === 0 || message.say === 'user_feedback' ? 'user' : 'assistant';
288368

289369
const name = role === 'user' ? 'User' : 'Roo Code';

0 commit comments

Comments
 (0)