fix(chat): tuck the reaction affordance onto the message-bubble corner (#4018)

This commit is contained in:
Cyrus Gray
2026-06-24 02:15:58 +05:30
committed by GitHub
parent 025a7fad40
commit a1cf4ad077
+100 -96
View File
@@ -1858,30 +1858,109 @@ const Conversations = ({
}`}>
{msg.sender === 'agent' ? (
<div className="space-y-1">
{agentMessageViewMode === 'text' ? (
<AgentMessageText content={msg.content} />
) : (
splitAgentMessageIntoBubbles(msg.content).map(
(segment, index, parts) => {
const position: AgentBubblePosition =
parts.length === 1
? 'single'
: index === 0
? 'first'
: index === parts.length - 1
? 'last'
: 'middle';
<div className="relative space-y-1">
{agentMessageViewMode === 'text' ? (
<AgentMessageText content={msg.content} />
) : (
splitAgentMessageIntoBubbles(msg.content).map(
(segment, index, parts) => {
const position: AgentBubblePosition =
parts.length === 1
? 'single'
: index === 0
? 'first'
: index === parts.length - 1
? 'last'
: 'middle';
return (
<AgentMessageBubble
key={`${msg.id}:${index}`}
content={segment}
position={position}
/>
);
}
)
)}
{/* Reaction affordance — the closed "+", the open picker,
and the resulting reaction chips all live here, tucked
onto the bubble's bottom-left corner so the control
never jumps to a separate row below the timestamp. */}
{latestVisibleMessage?.id === msg.id &&
(() => {
const myReactions =
(msg.extraMetadata?.myReactions as string[] | undefined) ?? [];
const pickerOpen = reactionPickerMsgId === msg.id;
return (
<AgentMessageBubble
key={`${msg.id}:${index}`}
content={segment}
position={position}
/>
<div className="absolute -bottom-2 left-3 z-10 flex items-center gap-1">
{myReactions.map(emoji => (
<button
key={emoji}
type="button"
data-analytics-id="chat-message-reaction-remove"
onClick={() =>
selectedThreadId &&
void dispatch(
persistReaction({
threadId: selectedThreadId,
messageId: msg.id,
emoji,
})
)
}
className="flex items-center rounded-full border border-primary-200 bg-primary-100 px-1.5 text-xs leading-[1.5] shadow-sm transition-colors hover:bg-primary-200 dark:border-primary-400/40 dark:bg-primary-500/25"
title={t('chat.removeReaction').replace('{emoji}', emoji)}>
{emoji}
</button>
))}
{pickerOpen ? (
<div className="flex items-center gap-0.5 rounded-full bg-white px-1 py-0.5 shadow-sm ring-1 ring-stone-200 dark:bg-neutral-900 dark:ring-neutral-700">
{['👍', '❤️', '😂', '🔥', '👀', '🎯'].map(emoji => (
<button
key={emoji}
type="button"
data-analytics-id="chat-message-reaction-pick"
onClick={() => {
if (selectedThreadId) {
void dispatch(
persistReaction({
threadId: selectedThreadId,
messageId: msg.id,
emoji,
})
);
}
setReactionPickerMsgId(null);
}}
className="rounded px-0.5 text-sm transition-transform hover:scale-125"
title={emoji}>
{emoji}
</button>
))}
<button
type="button"
data-analytics-id="chat-message-reaction-close"
onClick={() => setReactionPickerMsgId(null)}
className="ml-0.5 px-0.5 text-xs text-stone-600 hover:text-stone-400 dark:text-neutral-300 dark:hover:text-neutral-500">
</button>
</div>
) : (
<button
type="button"
data-analytics-id="chat-message-reaction-open"
onClick={() => setReactionPickerMsgId(msg.id)}
className="flex h-[18px] items-center rounded-full bg-white px-1.5 text-xs leading-none text-stone-500 opacity-0 shadow-sm ring-1 ring-stone-200 transition-opacity hover:bg-stone-100 hover:text-stone-700 group-hover/msg:opacity-100 dark:bg-neutral-900 dark:text-neutral-400 dark:ring-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
title={t('chat.addReaction')}
aria-label={t('chat.addReaction')}>
+
</button>
)}
</div>
);
}
)
)}
})()}
</div>
{(() => {
const raw = msg.extraMetadata?.citations;
if (!Array.isArray(raw)) return null;
@@ -2025,81 +2104,6 @@ const Conversations = ({
</svg>
)}
</button>
{(() => {
if (latestVisibleMessage?.id !== msg.id) return null;
const myReactions =
(msg.extraMetadata?.myReactions as string[] | undefined) ?? [];
const hasReactions = myReactions.length > 0;
// Show reaction row only for the most recent visible message.
if (!hasReactions && msg.sender !== 'agent') return null;
return (
<div className="mt-1 flex items-center gap-1 flex-wrap min-h-[20px]">
{myReactions.map(emoji => (
<button
key={emoji}
type="button"
data-analytics-id="chat-message-reaction-remove"
onClick={() =>
selectedThreadId &&
void dispatch(
persistReaction({
threadId: selectedThreadId,
messageId: msg.id,
emoji,
})
)
}
className="flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-primary-100 border border-primary-200 text-xs transition-colors hover:bg-primary-200"
title={t('chat.removeReaction').replace('{emoji}', emoji)}>
{emoji}
</button>
))}
{msg.sender === 'agent' &&
(reactionPickerMsgId === msg.id ? (
<div className="flex items-center gap-0.5 px-1 py-0.5 rounded-full bg-stone-100 dark:bg-neutral-800">
{['👍', '❤️', '😂', '🔥', '👀', '🎯'].map(emoji => (
<button
key={emoji}
type="button"
data-analytics-id="chat-message-reaction-pick"
onClick={() => {
if (selectedThreadId) {
void dispatch(
persistReaction({
threadId: selectedThreadId,
messageId: msg.id,
emoji,
})
);
}
setReactionPickerMsgId(null);
}}
className="px-0.5 rounded text-sm hover:scale-125 transition-transform"
title={emoji}>
{emoji}
</button>
))}
<button
type="button"
data-analytics-id="chat-message-reaction-close"
onClick={() => setReactionPickerMsgId(null)}
className="ml-0.5 text-stone-600 dark:text-neutral-300 hover:text-stone-400 dark:hover:text-neutral-500 text-xs px-0.5">
</button>
</div>
) : (
<button
type="button"
data-analytics-id="chat-message-reaction-open"
onClick={() => setReactionPickerMsgId(msg.id)}
className="opacity-0 group-hover/msg:opacity-100 flex items-center px-1.5 py-0.5 rounded-full bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-200 dark:bg-neutral-800 dark:hover:bg-neutral-800 text-stone-500 dark:text-neutral-400 hover:text-stone-300 dark:hover:text-neutral-600 text-xs transition-all"
title={t('chat.addReaction')}>
+
</button>
))}
</div>
);
})()}
</div>
</div>
</div>