theo-agent-dashboard

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

Message.tsx (5524B)


      1 import { useState, useCallback } from "react";
      2 import type { Message as MessageType } from "../../store/chat";
      3 import { MarkdownRenderer } from "./MarkdownRenderer";
      4 import { ToolCall } from "../ToolCall/ToolCall";
      5 
      6 interface MessageProps {
      7   message: MessageType;
      8   onEdit?: (id: string, newContent: string) => void;
      9   onRegenerate?: (id: string) => void;
     10   showForkButton?: boolean;
     11   onFork?: () => void;
     12 }
     13 
     14 export function Message({ message, onEdit, onRegenerate }: MessageProps) {
     15   const [isEditing, setIsEditing] = useState(false);
     16   const [editContent, setEditContent] = useState(message.content);
     17 
     18   const isUser = message.role === "user";
     19   const showActions = (isUser || !isUser) && (onEdit || onRegenerate);
     20 
     21   const handleSave = useCallback(() => {
     22     if (editContent.trim() && onEdit) {
     23       onEdit(message.id, editContent.trim());
     24     }
     25     setIsEditing(false);
     26   }, [editContent, message.id, onEdit]);
     27 
     28   const handleCancel = useCallback(() => {
     29     setEditContent(message.content);
     30     setIsEditing(false);
     31   }, [message.content]);
     32 
     33   const handleKeyDown = useCallback(
     34     (e: React.KeyboardEvent) => {
     35       if (e.key === "Escape") handleCancel();
     36       if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleSave();
     37     },
     38     [handleCancel, handleSave],
     39   );
     40 
     41   return (
     42     <div className={`flex ${isUser ? "justify-end" : "justify-start"}`}>
     43       <div
     44         className={`group relative max-w-[80%] rounded-2xl px-4 py-3 ${
     45           isUser ? "bg-gray-800 text-gray-100" : "bg-gray-900 text-gray-100"
     46         }`}
     47       >
     48         {showActions && (
     49           <div className="absolute -top-8 left-0 hidden gap-1 group-hover:flex">
     50             {isUser && onEdit && (
     51               <button
     52                 type="button"
     53                 aria-label="Edit message"
     54                 onClick={() => setIsEditing(true)}
     55                 className="rounded bg-gray-700 px-2 py-1 text-xs text-gray-300 hover:bg-gray-600"
     56               >
     57                 ✏️
     58               </button>
     59             )}
     60             {!isUser && onRegenerate && (
     61               <button
     62                 type="button"
     63                 aria-label="Regenerate response"
     64                 onClick={() => onRegenerate(message.id)}
     65                 className="rounded bg-gray-700 px-2 py-1 text-xs text-gray-300 hover:bg-gray-600"
     66               >
     67                 🔄
     68               </button>
     69             )}
     70           </div>
     71         )}
     72 
     73         {message.toolCalls.length > 0 && (
     74           <div className="mb-3 space-y-1">
     75             {message.toolCalls.map((tc) => (
     76               <ToolCall key={tc.id} toolCall={tc} />
     77             ))}
     78           </div>
     79         )}
     80 
     81         {isEditing ? (
     82           <div className="space-y-2">
     83             <textarea
     84               value={editContent}
     85               onChange={(e) => setEditContent(e.target.value)}
     86               onKeyDown={handleKeyDown}
     87               className="w-full resize-none rounded border border-gray-600 bg-gray-950 px-2 py-1 text-sm text-gray-100 focus:border-gray-400 focus:outline-none"
     88               rows={3}
     89             />
     90             <div className="flex gap-2">
     91               <button
     92                 type="button"
     93                 aria-label="Save edit"
     94                 onClick={handleSave}
     95                 className="rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-500"
     96               >
     97                 Save
     98               </button>
     99               <button
    100                 type="button"
    101                 onClick={handleCancel}
    102                 className="rounded bg-gray-700 px-2 py-1 text-xs text-gray-300 hover:bg-gray-600"
    103               >
    104                 Cancel
    105               </button>
    106             </div>
    107           </div>
    108         ) : (
    109           <>
    110             {isUser &&
    111               message.attachments &&
    112               message.attachments.filter((a) => a.content_type.startsWith("image/"))
    113                 .length > 0 && (
    114                 <div className="mb-2 flex flex-wrap gap-2">
    115                   {message.attachments
    116                     .filter((a) => a.content_type.startsWith("image/"))
    117                     .map((att) => (
    118                       <a
    119                         key={att.id}
    120                         href={`/api/attachments/${att.id}`}
    121                         target="_blank"
    122                         rel="noopener noreferrer"
    123                       >
    124                         <img
    125                           src={`/api/attachments/${att.id}`}
    126                           alt={att.filename}
    127                           className="max-h-[200px] w-auto rounded object-cover"
    128                         />
    129                       </a>
    130                     ))}
    131                 </div>
    132               )}
    133 
    134             {message.content && (
    135               <div className="text-sm leading-relaxed">
    136                 {isUser ? (
    137                   <p className="whitespace-pre-wrap">{message.content}</p>
    138                 ) : (
    139                   <MarkdownRenderer content={message.content} />
    140                 )}
    141               </div>
    142             )}
    143           </>
    144         )}
    145 
    146         {message.model && (
    147           <div className="mt-2 text-xs text-gray-500">{message.model}</div>
    148         )}
    149 
    150         {message.forkId && message.originalContent && (
    151           <div className="mt-2 text-xs">
    152             <button
    153               type="button"
    154               onClick={() => alert(message.originalContent)}
    155               className="text-blue-400 hover:text-blue-300"
    156             >
    157               View original
    158             </button>
    159           </div>
    160         )}
    161       </div>
    162     </div>
    163   );
    164 }