From 014d10c77188166b8a043bb57f0eb2e5e2b3afd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 6 Feb 2026 23:53:58 +0000 Subject: [PATCH] feat: added message actions (#203) --- .../components/task/chat/message-renderer.tsx | 14 +- .../task/chat/messages/agent-status.tsx | 50 +--- .../task/chat/messages/chat-message.tsx | 275 +++++++++++------- .../task/chat/messages/code-block.tsx | 16 +- .../task/chat/messages/expandable-row.tsx | 8 +- .../task/chat/messages/inline-code.tsx | 24 +- .../task/chat/messages/message-actions.tsx | 124 ++++++++ .../task/chat/messages/tool-edit-message.tsx | 4 +- .../task/chat/messages/turn-group-message.tsx | 6 + .../task/chat/virtualized-message-list.tsx | 17 ++ apps/web/hooks/use-copy-to-clipboard.ts | 17 ++ apps/web/hooks/use-message-navigation.ts | 35 +++ 12 files changed, 409 insertions(+), 181 deletions(-) create mode 100644 apps/web/components/task/chat/messages/message-actions.tsx create mode 100644 apps/web/hooks/use-copy-to-clipboard.ts create mode 100644 apps/web/hooks/use-message-navigation.ts diff --git a/apps/web/components/task/chat/message-renderer.tsx b/apps/web/components/task/chat/message-renderer.tsx index 1598133159..6dd16d55c1 100644 --- a/apps/web/components/task/chat/message-renderer.tsx +++ b/apps/web/components/task/chat/message-renderer.tsx @@ -25,6 +25,8 @@ type AdapterContext = { worktreePath?: string; sessionId?: string; onOpenFile?: (path: string) => void; + allMessages?: Message[]; + onScrollToMessage?: (messageId: string) => void; }; type MessageAdapter = { @@ -125,6 +127,8 @@ const adapters: MessageAdapter[] = [ label="Task" className="bg-amber-500/10 text-foreground border-amber-500/30" showRichBlocks + allMessages={ctx.allMessages} + onScrollToMessage={ctx.onScrollToMessage} /> ); } @@ -134,6 +138,8 @@ const adapters: MessageAdapter[] = [ comment={comment} label="You" className="bg-primary/10 text-foreground border-primary/30" + allMessages={ctx.allMessages} + onScrollToMessage={ctx.onScrollToMessage} /> ); } @@ -143,6 +149,8 @@ const adapters: MessageAdapter[] = [ label="Agent" className="bg-muted/40 text-foreground border-border/60" showRichBlocks={comment.type === 'message' || comment.type === 'content' || !comment.type} + allMessages={ctx.allMessages} + onScrollToMessage={ctx.onScrollToMessage} /> ); }, @@ -158,6 +166,8 @@ type MessageRendererProps = { worktreePath?: string; sessionId?: string; onOpenFile?: (path: string) => void; + allMessages?: Message[]; + onScrollToMessage?: (messageId: string) => void; }; export const MessageRenderer = memo(function MessageRenderer({ @@ -169,8 +179,10 @@ export const MessageRenderer = memo(function MessageRenderer({ worktreePath, sessionId, onOpenFile, + allMessages, + onScrollToMessage, }: MessageRendererProps) { - const ctx = { isTaskDescription, taskId, permissionsByToolCallId, childrenByParentToolCallId, worktreePath, sessionId, onOpenFile }; + const ctx = { isTaskDescription, taskId, permissionsByToolCallId, childrenByParentToolCallId, worktreePath, sessionId, onOpenFile, allMessages, onScrollToMessage }; const adapter = adapters.find((entry) => entry.matches(comment, ctx)) ?? adapters[adapters.length - 1]; return adapter.render(comment, ctx); }); diff --git a/apps/web/components/task/chat/messages/agent-status.tsx b/apps/web/components/task/chat/messages/agent-status.tsx index 35828dcea1..77eb2f20fd 100644 --- a/apps/web/components/task/chat/messages/agent-status.tsx +++ b/apps/web/components/task/chat/messages/agent-status.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useCallback, useMemo, useState, useEffect } from 'react'; -import { IconAlertCircle, IconAlertTriangle, IconCopy, IconCheck } from '@tabler/icons-react'; +import { useMemo, useState, useEffect } from 'react'; +import { IconAlertCircle, IconAlertTriangle } from '@tabler/icons-react'; import type { Message, TaskSessionState } from '@/lib/types/http'; import { useSessionTurn } from '@/hooks/domains/session/use-session-turn'; import { useAppStore } from '@/components/state-provider'; @@ -109,33 +109,6 @@ function useRunningTimer(isRunning: boolean, turnStartedAt: string | null) { return { elapsedSeconds: displaySeconds, formatted }; } -function CopyButton({ content }: { content: string }) { - const [copied, setCopied] = useState(false); - - const handleCopy = useCallback(async () => { - try { - await navigator.clipboard.writeText(content); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy:', err); - } - }, [content]); - - return ( - - ); -} export function AgentStatus({ sessionState, sessionId, messages = [] }: AgentStatusProps) { const { lastTurnDuration, isActive: isTurnActive } = useSessionTurn(sessionId); @@ -156,16 +129,6 @@ export function AgentStatus({ sessionState, sessionId, messages = [] }: AgentSta const isError = config?.icon === 'error'; const isWarning = config?.icon === 'warning'; - // Get last agent message content for copy button - const lastAgentContent = useMemo(() => { - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].author_type === 'agent' && messages[i].content) { - return messages[i].content; - } - } - return null; - }, [messages]); - // Calculate duration from messages as fallback const fallbackDuration = useMemo(() => { if (lastTurnDuration) return null; // Don't need fallback @@ -219,14 +182,13 @@ export function AgentStatus({ sessionState, sessionId, messages = [] }: AgentSta ); } - // Completed/idle state - show duration and copy button if available - const showCompletedState = !isTurnActive && (displayDuration || lastAgentContent); + // Completed/idle state - show duration if available + const showCompletedState = !isTurnActive && displayDuration; if (showCompletedState) { return (
- - {displayDuration && {displayDuration}} - {lastAgentContent && } + + {displayDuration}
); diff --git a/apps/web/components/task/chat/messages/chat-message.tsx b/apps/web/components/task/chat/messages/chat-message.tsx index 5b69b2a9fc..3ae02f84be 100644 --- a/apps/web/components/task/chat/messages/chat-message.tsx +++ b/apps/web/components/task/chat/messages/chat-message.tsx @@ -1,6 +1,6 @@ 'use client'; -import { memo, isValidElement, type ReactNode } from 'react'; +import { memo, isValidElement, useState, type ReactNode } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; @@ -9,6 +9,8 @@ import type { Message } from '@/lib/types/http'; import { RichBlocks } from '@/components/task/chat/messages/rich-blocks'; import { InlineCode } from '@/components/task/chat/messages/inline-code'; import { CodeBlock } from '@/components/task/chat/messages/code-block'; +import { MessageActions } from '@/components/task/chat/messages/message-actions'; +import { useMessageNavigation } from '@/hooks/use-message-navigation'; /** * Recursively extracts text content from React children. @@ -43,6 +45,8 @@ type ChatMessageProps = { label: string; className: string; showRichBlocks?: boolean; + allMessages?: Message[]; + onScrollToMessage?: (messageId: string) => void; }; // Regex to match @file references (file paths after @) @@ -87,10 +91,30 @@ function renderContentWithFileRefs(content: string): React.ReactNode[] { return parts.length > 0 ? parts : [content]; } -export const ChatMessage = memo(function ChatMessage({ comment, label, className, showRichBlocks }: ChatMessageProps) { +export const ChatMessage = memo(function ChatMessage({ comment, label, className, showRichBlocks, allMessages, onScrollToMessage }: ChatMessageProps) { + const [showRaw, setShowRaw] = useState(false); const isUser = comment.author_type === 'user'; const isTaskDescription = label === 'Task'; + // Navigation logic for user messages + const userNavigation = useMessageNavigation( + allMessages || [], + comment.id, + 'user' + ); + + const handleNavigatePrev = () => { + if (userNavigation.previous && onScrollToMessage) { + onScrollToMessage(userNavigation.previous.id); + } + }; + + const handleNavigateNext = () => { + if (userNavigation.next && onScrollToMessage) { + onScrollToMessage(userNavigation.next.id); + } + }; + // Keep the old card-based layout for task descriptions (amber banner) if (isTaskDescription) { return ( @@ -121,7 +145,7 @@ export const ChatMessage = memo(function ChatMessage({ comment, label, className return (
-
+
{/* Display image attachments */} {hasAttachments && ( @@ -145,13 +169,32 @@ export const ChatMessage = memo(function ChatMessage({ comment, label, className )} {/* Display text content */} {hasContent ? ( -

- {renderContentWithFileRefs(comment.content)} -

+ showRaw ? ( +
+                  {comment.content}
+                
+ ) : ( +

+ {renderContentWithFileRefs(comment.content)} +

+ ) ) : !hasAttachments ? (

(empty)

) : null}
+ 0} + isRawView={showRaw} + onToggleRaw={() => setShowRaw(!showRaw)} + onNavigatePrev={handleNavigatePrev} + onNavigateNext={handleNavigateNext} + hasPrev={userNavigation.hasPrevious} + hasNext={userNavigation.hasNext} + />
); @@ -159,110 +202,130 @@ export const ChatMessage = memo(function ChatMessage({ comment, label, className // Agent message: icon on left, no card background return ( -
+
{/* Content */}
-
- { - const content = getTextContent(children).replace(/\n$/, ''); - const hasLanguage = className?.startsWith('language-'); - const hasNewlines = content.includes('\n'); - - // Code block if it has a language specifier OR has multiple lines - if (hasLanguage || hasNewlines) { - return {content}; - } - - // Inline code (single backticks, single line) - return {content}; - }, - ol: ({ children }) => ( -
    - {children} -
- ), - ul: ({ children }) => ( -
    - {children} -
- ), - li: ({ children }) => ( -
  • - {children} -
  • - ), - p: ({ children }) => ( -

    - {children} -

    - ), - h1: ({ children }) => ( -

    - {children} -

    - ), - h2: ({ children }) => ( -

    - {children} -

    - ), - h3: ({ children }) => ( -

    - {children} -

    - ), - h4: ({ children }) => ( -

    - {children} -

    - ), - h5: ({ children }) => ( -

    - {children} -

    - ), - table: ({ children }) => ( -
    - - {children} -
    -
    - ), - thead: ({ children }) => ( - - {children} - - ), - tbody: ({ children }) => ( - - {children} - - ), - tr: ({ children }) => ( - - {children} - - ), - th: ({ children }) => ( - - {children} - - ), - td: ({ children }) => ( - - {children} - - ), - }} - > + {showRaw ? ( +
                 {comment.content || '(empty)'}
    -          
    -          {showRichBlocks ?  : null}
    -        
    + + ) : ( +
    + { + const content = getTextContent(children).replace(/\n$/, ''); + const hasLanguage = className?.startsWith('language-'); + const hasNewlines = content.includes('\n'); + + // Code block if it has a language specifier OR has multiple lines + if (hasLanguage || hasNewlines) { + return {content}; + } + + // Inline code (single backticks, single line) + return {content}; + }, + ol: ({ children }) => ( +
      + {children} +
    + ), + ul: ({ children }) => ( +
      + {children} +
    + ), + li: ({ children }) => ( +
  • + {children} +
  • + ), + p: ({ children }) => ( +

    + {children} +

    + ), + h1: ({ children }) => ( +

    + {children} +

    + ), + h2: ({ children }) => ( +

    + {children} +

    + ), + h3: ({ children }) => ( +

    + {children} +

    + ), + h4: ({ children }) => ( +

    + {children} +

    + ), + h5: ({ children }) => ( +

    + {children} +

    + ), + hr: ({ children }) => ( +
    + {children} + + ), + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + thead: ({ children }) => ( + + {children} + + ), + tbody: ({ children }) => ( + + {children} + + ), + tr: ({ children }) => ( + + {children} + + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + }} + > + {comment.content || '(empty)'} +
    + {showRichBlocks ? : null} +
    + )} + setShowRaw(!showRaw)} + />
    ); diff --git a/apps/web/components/task/chat/messages/code-block.tsx b/apps/web/components/task/chat/messages/code-block.tsx index ec14775a15..0e97022304 100644 --- a/apps/web/components/task/chat/messages/code-block.tsx +++ b/apps/web/components/task/chat/messages/code-block.tsx @@ -1,6 +1,5 @@ 'use client'; -import { useState } from 'react'; import { useTheme } from 'next-themes'; import CodeMirror from '@uiw/react-codemirror'; import { vscodeDark, vscodeLight } from '@uiw/codemirror-theme-vscode'; @@ -8,6 +7,7 @@ import { EditorView } from '@codemirror/view'; import { IconCheck, IconCopy } from '@tabler/icons-react'; import { cn } from '@/lib/utils'; import { getCodeMirrorExtension } from '@/lib/languages'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; type CodeBlockProps = { children: React.ReactNode; @@ -21,7 +21,7 @@ const getLanguageExtension = (language?: string) => { }; export function CodeBlock({ children, className }: CodeBlockProps) { - const [copied, setCopied] = useState(false); + const { copied, copy } = useCopyToClipboard(); const { theme, systemTheme } = useTheme(); const effectiveTheme = theme === 'system' ? systemTheme : theme; @@ -36,17 +36,11 @@ export function CodeBlock({ children, className }: CodeBlockProps) { }); const handleCopy = async () => { - try { - await navigator.clipboard.writeText(code); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy:', err); - } + await copy(code); }; return ( -
    +
    {/* Copy button */} + )} + + {/* Raw text toggle */} + {showRawToggle && onToggleRaw && ( + + )} + + {/* Navigation buttons */} + {showNavigation && ( + <> + + + + )} + + {/* Timestamp */} + {showTimestamp && ( + + {formatRelativeTime(message.created_at)} + + )} +
    + ); +} diff --git a/apps/web/components/task/chat/messages/tool-edit-message.tsx b/apps/web/components/task/chat/messages/tool-edit-message.tsx index 52ed5ce24a..d7cf977f16 100644 --- a/apps/web/components/task/chat/messages/tool-edit-message.tsx +++ b/apps/web/components/task/chat/messages/tool-edit-message.tsx @@ -121,7 +121,7 @@ export const ToolEditMessage = memo(function ToolEditMessage({ comment, worktree e.stopPropagation(); onOpenFile(filePath); }} - className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground shrink-0 cursor-pointer" + className="opacity-0 group-hover/expandable:opacity-100 transition-opacity text-muted-foreground hover:text-foreground shrink-0 cursor-pointer" title="Open file" > @@ -131,7 +131,7 @@ export const ToolEditMessage = memo(function ToolEditMessage({ comment, worktree
    diff --git a/apps/web/components/task/chat/virtualized-message-list.tsx b/apps/web/components/task/chat/virtualized-message-list.tsx index 35eea7e97e..ae91603959 100644 --- a/apps/web/components/task/chat/virtualized-message-list.tsx +++ b/apps/web/components/task/chat/virtualized-message-list.tsx @@ -55,6 +55,19 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({ overscan: 6, }); + // Scroll to a specific message by ID + const handleScrollToMessage = useCallback((messageId: string) => { + const index = items.findIndex(item => { + if (item.type === 'turn_group') { + return item.messages.some(msg => msg.id === messageId); + } + return item.message?.id === messageId; + }); + if (index >= 0) { + virtualizer.scrollToIndex(index, { align: 'center' }); + } + }, [items, virtualizer]); + // Use ref to track loading state to avoid recreating the scroll handler const loadingRef = useRef({ hasMore, isLoadingMore }); loadingRef.current = { hasMore, isLoadingMore }; @@ -182,6 +195,8 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({ onOpenFile={onOpenFile} isLastGroup={item.id === lastTurnGroupId} isTurnActive={isRunning} + allMessages={messages} + onScrollToMessage={handleScrollToMessage} /> ) : ( )}
    diff --git a/apps/web/hooks/use-copy-to-clipboard.ts b/apps/web/hooks/use-copy-to-clipboard.ts new file mode 100644 index 0000000000..417ea3fa23 --- /dev/null +++ b/apps/web/hooks/use-copy-to-clipboard.ts @@ -0,0 +1,17 @@ +import { useState, useCallback } from 'react'; + +export function useCopyToClipboard() { + const [copied, setCopied] = useState(false); + + const copy = useCallback(async (text: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }, []); + + return { copied, copy }; +} diff --git a/apps/web/hooks/use-message-navigation.ts b/apps/web/hooks/use-message-navigation.ts new file mode 100644 index 0000000000..1b763654e3 --- /dev/null +++ b/apps/web/hooks/use-message-navigation.ts @@ -0,0 +1,35 @@ +import { useMemo } from 'react'; +import type { Message } from '@/lib/types/http'; + +export function useMessageNavigation( + messages: Message[], + currentMessageId: string, + filterType: 'user' | 'agent' +) { + const currentIndex = useMemo(() => { + return messages.findIndex(msg => msg.id === currentMessageId); + }, [messages, currentMessageId]); + + const filteredIndices = useMemo(() => { + return messages + .map((msg, idx) => (msg.author_type === filterType ? idx : -1)) + .filter(idx => idx !== -1); + }, [messages, filterType]); + + const previous = useMemo(() => { + const validPrev = filteredIndices.filter(idx => idx < currentIndex); + return validPrev.length > 0 ? messages[validPrev[validPrev.length - 1]] : null; + }, [filteredIndices, currentIndex, messages]); + + const next = useMemo(() => { + const validNext = filteredIndices.filter(idx => idx > currentIndex); + return validNext.length > 0 ? messages[validNext[0]] : null; + }, [filteredIndices, currentIndex, messages]); + + return { + hasPrevious: previous !== null, + hasNext: next !== null, + previous, + next, + }; +}