Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions emoji-reactions-implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Emoji Reactions Feature Implementation

## Overview
Successfully implemented an emoji reaction feature that allows users to add emoji reactions to any task messages they can see in the Roo Code VS Code extension.

## Implementation Details

### 1. Message Type Extension
**File:** `packages/types/src/message.ts`
- Extended `ClineMessage` schema to include `reactions?: Record<string, number>`
- Reactions map emoji strings to reaction counts

### 2. EmojiReactions Component
**File:** `webview-ui/src/components/chat/EmojiReactions.tsx`
- Standalone React component for displaying and managing reactions
- Features:
- 16 common emoji picker (👍, 👎, ❤️, 😂, 😮, 😢, 😡, 🎉, 🚀, 👀, 💯, 🔥, ⭐, ✅, ❌, 🤔)
- Click-to-toggle reactions (click existing to remove, click new to add)
- Displays reaction counts as clickable buttons
- Outside-click to close picker functionality

### 3. Message Protocol Extension
**File:** `src/shared/WebviewMessage.ts`
- Added `"addReaction"` and `"removeReaction"` message types
- Added `messageTs?: number` and `emoji?: string` properties for reaction data

### 4. Backend Message Handling
**File:** `src/core/webview/webviewMessageHandler.ts`
- Added handlers for `addReaction` and `removeReaction` messages
- Delegates to Task class methods for processing

### 5. Task Class Methods
**File:** `src/core/task/Task.ts`
- `addReaction(messageTs: number, emoji: string)`: Increments reaction count
- `removeReaction(messageTs: number, emoji: string)`: Decrements reaction count
- Automatic persistence and webview state synchronization

### 6. UI Integration
**File:** `webview-ui/src/components/chat/ChatRow.tsx`
- Integrated EmojiReactions component into key message types:
- Text messages (`message.say === "text"`)
- Completion results (`message.say === "completion_result"` and `message.ask === "completion_result"`)
- User feedback messages (`message.say === "user_feedback"`)
- Reactions only display for complete (non-partial) messages
- Handlers for adding/removing reactions via VSCode message passing

## Key Features

### User Experience
- **Intuitive interaction**: Click existing reactions to remove, click new emojis to add
- **Visual feedback**: Reaction counts displayed on buttons
- **Easy access**: Emoji picker appears on hover with smiling face icon
- **Persistent**: Reactions are saved with messages and persist across sessions

### Technical Features
- **Real-time updates**: Changes sync immediately across the interface
- **Proper persistence**: Reactions saved to task message storage
- **Type safety**: Full TypeScript support with proper type definitions
- **Performance**: Minimal re-renders with proper React optimization

### Message Types Supporting Reactions
1. **Text responses** from the AI assistant
2. **Task completion results** (both ask and say types)
3. **User feedback messages** that users send

## Usage
Users can now:
1. See a small 😊 button appear on eligible messages
2. Click it to open an emoji picker with 16 common reaction emojis
3. Click any emoji to add a reaction
4. Click existing reaction buttons to remove their reaction
5. See reaction counts update in real-time

The feature seamlessly integrates into the existing chat interface without disrupting the current user experience while adding a new dimension of interaction and feedback capability.
1 change: 1 addition & 0 deletions packages/types/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export const clineMessageSchema = z.object({
progressStatus: toolProgressStatusSchema.optional(),
contextCondense: contextCondenseSchema.optional(),
isProtected: z.boolean().optional(),
reactions: z.record(z.string(), z.number()).optional(), // emoji -> count mapping
})

export type ClineMessage = z.infer<typeof clineMessageSchema>
Expand Down
28 changes: 28 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,34 @@ export class Task extends EventEmitter<ClineEvents> {
await this.saveClineMessages()
}

public async addReaction(messageTs: number, emoji: string) {
const message = this.clineMessages.find(m => m.ts === messageTs)
if (message) {
if (!message.reactions) {
message.reactions = {}
}
message.reactions[emoji] = (message.reactions[emoji] || 0) + 1
await this.saveClineMessages()

const provider = this.providerRef.deref()
await provider?.postStateToWebview()
}
}

public async removeReaction(messageTs: number, emoji: string) {
const message = this.clineMessages.find(m => m.ts === messageTs)
if (message && message.reactions && message.reactions[emoji]) {
message.reactions[emoji] = Math.max(0, message.reactions[emoji] - 1)
if (message.reactions[emoji] === 0) {
delete message.reactions[emoji]
}
await this.saveClineMessages()

const provider = this.providerRef.deref()
await provider?.postStateToWebview()
}
}

private async updateClineMessage(message: ClineMessage) {
const provider = this.providerRef.deref()
await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message })
Expand Down
16 changes: 16 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1727,5 +1727,21 @@ export const webviewMessageHandler = async (
}
break
}
case "addReaction":
if (message.messageTs !== undefined && message.emoji) {
const currentTask = provider.getCurrentCline()
if (currentTask) {
await currentTask.addReaction(message.messageTs, message.emoji)
}
}
break
case "removeReaction":
if (message.messageTs !== undefined && message.emoji) {
const currentTask = provider.getCurrentCline()
if (currentTask) {
await currentTask.removeReaction(message.messageTs, message.emoji)
}
}
break
}
}
4 changes: 4 additions & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ export interface WebviewMessage {
| "switchTab"
| "profileThresholds"
| "shareTaskSuccess"
| "addReaction"
| "removeReaction"
text?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account"
disabled?: boolean
Expand Down Expand Up @@ -213,6 +215,8 @@ export interface WebviewMessage {
mpInstallOptions?: InstallMarketplaceItemOptions
config?: Record<string, any> // Add config to the payload
visibility?: ShareVisibility // For share visibility
messageTs?: number // For reaction messages
emoji?: string // For reaction messages
}

export const checkoutDiffPayloadSchema = z.object({
Expand Down
47 changes: 47 additions & 0 deletions webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { CommandExecutionError } from "./CommandExecutionError"
import { AutoApprovedRequestLimitWarning } from "./AutoApprovedRequestLimitWarning"
import { CondenseContextErrorRow, CondensingContextRow, ContextCondenseRow } from "./ContextCondenseRow"
import CodebaseSearchResultsDisplay from "./CodebaseSearchResultsDisplay"
import EmojiReactions from "./EmojiReactions"

interface ChatRowProps {
message: ClineMessage
Expand Down Expand Up @@ -112,6 +113,23 @@ export const ChatRowContent = ({
onToggleExpand(message.ts)
}, [onToggleExpand, message.ts])

// Emoji reaction handlers
const handleAddReaction = useCallback((emoji: string) => {
vscode.postMessage({
type: "addReaction",
messageTs: message.ts,
emoji
})
}, [message.ts])

const handleRemoveReaction = useCallback((emoji: string) => {
vscode.postMessage({
type: "removeReaction",
messageTs: message.ts,
emoji
})
}, [message.ts])

const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => {
if (message.text !== null && message.text !== undefined && message.say === "api_req_started") {
const info = safeJsonParse<ClineApiReqInfo>(message.text)
Expand Down Expand Up @@ -975,6 +993,14 @@ export const ChatRowContent = ({
return (
<div>
<Markdown markdown={message.text} partial={message.partial} />
{!message.partial && (
<EmojiReactions
messageTs={message.ts}
reactions={message.reactions}
onAddReaction={handleAddReaction}
onRemoveReaction={handleRemoveReaction}
/>
)}
</div>
)
case "user_feedback":
Expand All @@ -999,6 +1025,13 @@ export const ChatRowContent = ({
{message.images && message.images.length > 0 && (
<Thumbnails images={message.images} style={{ marginTop: "8px" }} />
)}
<EmojiReactions
messageTs={message.ts}
reactions={message.reactions}
onAddReaction={handleAddReaction}
onRemoveReaction={handleRemoveReaction}
className="px-2 pb-1"
/>
</div>
)
case "user_feedback_diff":
Expand Down Expand Up @@ -1035,6 +1068,12 @@ export const ChatRowContent = ({
</div>
<div style={{ color: "var(--vscode-charts-green)", paddingTop: 10 }}>
<Markdown markdown={message.text} />
<EmojiReactions
messageTs={message.ts}
reactions={message.reactions}
onAddReaction={handleAddReaction}
onRemoveReaction={handleRemoveReaction}
/>
</div>
</>
)
Expand Down Expand Up @@ -1191,6 +1230,14 @@ export const ChatRowContent = ({
</div>
<div style={{ color: "var(--vscode-charts-green)", paddingTop: 10 }}>
<Markdown markdown={message.text} partial={message.partial} />
{!message.partial && (
<EmojiReactions
messageTs={message.ts}
reactions={message.reactions}
onAddReaction={handleAddReaction}
onRemoveReaction={handleRemoveReaction}
/>
)}
</div>
</div>
)
Expand Down
107 changes: 107 additions & 0 deletions webview-ui/src/components/chat/EmojiReactions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import React, { useState, useRef, useEffect } from "react"
import { Button } from "@src/components/ui"
import { cn } from "@src/lib/utils"

interface EmojiReactionsProps {
messageTs: number
reactions?: Record<string, number>
onAddReaction: (emoji: string) => void
onRemoveReaction: (emoji: string) => void
className?: string
}

const COMMON_EMOJIS = [
"👍", "👎", "❤️", "😂", "😮", "😢", "😡", "🎉",
"🚀", "👀", "💯", "🔥", "⭐", "✅", "❌", "🤔"
]

export const EmojiReactions = ({
messageTs,
reactions = {},
onAddReaction,
onRemoveReaction,
className,
}) => {
const [showPicker, setShowPicker] = useState(false)
const pickerRef = useRef<HTMLDivElement>(null)

// Close picker when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) {
setShowPicker(false)
}
}

if (showPicker) {
document.addEventListener("mousedown", handleClickOutside)
}

return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [showPicker])

const handleEmojiClick = (emoji: string) => {
if (reactions && reactions[emoji] && reactions[emoji] > 0) {
onRemoveReaction(emoji)
} else {
onAddReaction(emoji)
}
setShowPicker(false)
}

const hasReactions = Object.keys(reactions).some(emoji => reactions[emoji] > 0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The computed variable 'hasReactions' is declared but not used. Remove it to simplify the code.

Suggested change
const hasReactions = Object.keys(reactions).some(emoji => reactions[emoji] > 0)


return (
<div className={cn("relative flex items-center gap-1 mt-2", className)}>
{/* Existing reactions */}
{reactions && Object.entries(reactions)
.filter(([_, count]) => (count as number) > 0)
.map(([emoji, count]) => (
<Button
key={emoji}
variant="outline"
size="sm"
className="h-6 px-2 py-0 text-xs bg-vscode-button-secondaryBackground hover:bg-vscode-button-secondaryHoverBackground border-vscode-button-border"
onClick={() => handleEmojiClick(emoji)}
>
<span className="mr-1">{emoji}</span>
<span>{count as number}</span>
</Button>
))}

{/* Add reaction button */}
<div className="relative" ref={pickerRef}>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-xs hover:bg-vscode-button-secondaryHoverBackground"
onClick={() => setShowPicker(!showPicker)}
title="Add reaction"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider localizing the 'Add reaction' button title instead of hardcoding it. Use a translation function (e.g., t('emojiReactions.add')) to support multiple languages.

Suggested change
title="Add reaction"
title={t('emojiReactions.add')}

This comment was generated because it violated a code review rule: irule_C0ez7Rji6ANcGkkX.

>
<span className="text-sm">😊</span>
</Button>

{/* Emoji picker */}
{showPicker && (
<div className="absolute top-full left-0 mt-1 p-2 bg-vscode-dropdown-background border border-vscode-dropdown-border rounded shadow-lg z-50 grid grid-cols-8 gap-1 max-w-64">
{COMMON_EMOJIS.map((emoji) => (
<Button
key={emoji}
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:bg-vscode-list-hoverBackground"
onClick={() => handleEmojiClick(emoji)}
>
{emoji}
</Button>
))}
</div>
)}
</div>
</div>
)
}

export default EmojiReactions
Loading