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 }) => (
-
- ),
- li: ({ children }) => (
-
- {children}
-
- ),
- p: ({ children }) => (
-
- {children}
-
- ),
- h1: ({ children }) => (
-
- {children}
-
- ),
- h2: ({ children }) => (
-
- {children}
-
- ),
- h3: ({ children }) => (
-
- {children}
-
- ),
- h4: ({ children }) => (
-
- {children}
-
- ),
- h5: ({ children }) => (
-
- {children}
-
- ),
- table: ({ 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 }) => (
+
+ ),
+ 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 }) => (
+
+ ),
+ 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 */}
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,
+ };
+}