Skip to content

Custom Actions

Add custom action buttons to messages, like feedback buttons.

Overview

Message actions appear on hover or after a message, allowing users to:

  • Like/dislike responses
  • Copy message content
  • Regenerate responses
  • Custom actions

Render Message Actions

tsx
import { Chat, ThumbsUpIcon, ThumbsDownIcon } from "@cognipeer/chat-ui";

function MessageActions({ message, isStreaming }) {
  // Don't show actions while streaming
  if (isStreaming) return null;
  
  // Only show for assistant messages
  if (message.role !== "assistant") return null;

  const handleFeedback = (type: "up" | "down") => {
    console.log(`Feedback ${type} for message:`, message.id);
    
    // Send to your API
    fetch("/api/feedback", {
      method: "POST",
      body: JSON.stringify({
        messageId: message.id,
        feedback: type,
      }),
    });
  };

  return (
    <div className="flex gap-2">
      <button
        onClick={() => handleFeedback("up")}
        className="p-1 hover:bg-gray-700 rounded"
        title="Good response"
      >
        <ThumbsUpIcon className="w-4 h-4" />
      </button>
      <button
        onClick={() => handleFeedback("down")}
        className="p-1 hover:bg-gray-700 rounded"
        title="Bad response"
      >
        <ThumbsDownIcon className="w-4 h-4" />
      </button>
    </div>
  );
}

<Chat
  baseUrl="/api/agents"
  agentId="assistant"
  renderMessageActions={(props) => <MessageActions {...props} />}
/>

Available Icons

tsx
import {
  ThumbsUpIcon,
  ThumbsDownIcon,
  CopyIcon,
  RefreshIcon,
  EditIcon,
  DeleteIcon,
} from "@cognipeer/chat-ui";

Copy Button

tsx
function MessageActions({ message, isStreaming }) {
  if (isStreaming) return null;

  const handleCopy = async () => {
    const text = typeof message.content === "string"
      ? message.content
      : message.content.map(p => p.type === "text" ? p.text : "").join("");
    
    await navigator.clipboard.writeText(text);
    // Show toast notification
  };

  return (
    <button onClick={handleCopy} title="Copy">
      <CopyIcon className="w-4 h-4" />
    </button>
  );
}

Regenerate Button

tsx
import { useChat } from "@cognipeer/chat-ui";

function RegenerateAction({ message }) {
  const { retry } = useChat({ /* ... */ });

  // Only show for the last assistant message
  // (requires additional logic to determine)

  return (
    <button onClick={retry} title="Regenerate">
      <RefreshIcon className="w-4 h-4" />
    </button>
  );
}

Feedback with State

Track feedback state:

tsx
import { useState } from "react";

function FeedbackActions({ message }) {
  const [feedback, setFeedback] = useState<"up" | "down" | null>(null);

  const handleFeedback = async (type: "up" | "down") => {
    if (feedback === type) {
      // Remove feedback
      setFeedback(null);
      await removeFeedback(message.id);
    } else {
      // Set feedback
      setFeedback(type);
      await submitFeedback(message.id, type);
    }
  };

  return (
    <div className="flex gap-1">
      <button
        onClick={() => handleFeedback("up")}
        className={feedback === "up" ? "text-green-500" : "text-gray-400"}
      >
        <ThumbsUpIcon className="w-4 h-4" />
      </button>
      <button
        onClick={() => handleFeedback("down")}
        className={feedback === "down" ? "text-red-500" : "text-gray-400"}
      >
        <ThumbsDownIcon className="w-4 h-4" />
      </button>
    </div>
  );
}

Detailed Feedback Modal

tsx
import { useState } from "react";

function DetailedFeedback({ message, isStreaming }) {
  const [showModal, setShowModal] = useState(false);
  const [feedbackType, setFeedbackType] = useState<"up" | "down" | null>(null);

  if (isStreaming || message.role !== "assistant") return null;

  const handleSubmit = async (comment: string) => {
    await fetch("/api/feedback", {
      method: "POST",
      body: JSON.stringify({
        messageId: message.id,
        type: feedbackType,
        comment,
      }),
    });
    setShowModal(false);
  };

  return (
    <>
      <button onClick={() => { setFeedbackType("up"); setShowModal(true); }}>
        👍
      </button>
      <button onClick={() => { setFeedbackType("down"); setShowModal(true); }}>
        👎
      </button>
      
      {showModal && (
        <FeedbackModal
          type={feedbackType}
          onSubmit={handleSubmit}
          onClose={() => setShowModal(false)}
        />
      )}
    </>
  );
}

Action Props

The renderMessageActions function receives:

typescript
interface MessageActionProps {
  message: Message;
  isStreaming: boolean;
  isLast: boolean;
}

Styling Actions

css
.message-actions {
  display: flex;
  gap: 4px;
  opacity: 0;
  transition: opacity 0.2s;
}

.message:hover .message-actions {
  opacity: 1;
}

.message-actions button {
  padding: 4px;
  border-radius: 4px;
  color: var(--chat-text-tertiary);
}

.message-actions button:hover {
  background: var(--chat-bg-hover);
  color: var(--chat-text-primary);
}

Multiple Action Groups

tsx
function MessageActions({ message, isStreaming }) {
  if (isStreaming) return null;

  return (
    <div className="flex justify-between">
      {/* Left actions */}
      <div className="flex gap-1">
        <CopyButton message={message} />
        {message.role === "assistant" && <RegenerateButton />}
      </div>
      
      {/* Right actions */}
      <div className="flex gap-1">
        {message.role === "assistant" && (
          <>
            <FeedbackButton type="up" message={message} />
            <FeedbackButton type="down" message={message} />
          </>
        )}
      </div>
    </div>
  );
}

Next Steps

Released under the MIT License.