feat: added message actions (#203)

This commit is contained in:
Carlos Florêncio
2026-02-06 23:53:58 +00:00
committed by GitHub
parent 5f2be4e161
commit 014d10c771
12 changed files with 409 additions and 181 deletions
@@ -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);
});
@@ -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 (
<button
onClick={handleCopy}
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-muted/50 text-muted-foreground/60 hover:text-muted-foreground transition-colors"
title="Copy last response"
>
{copied ? (
<IconCheck className="h-3.5 w-3.5 text-emerald-500" />
) : (
<IconCopy className="h-3.5 w-3.5" />
)}
</button>
);
}
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 (
<div className="flex items-center gap-2 py-2">
<span className="inline-flex items-center gap-2 text-xs text-muted-foreground">
{displayDuration && <span className="tabular-nums">{displayDuration}</span>}
{lastAgentContent && <CopyButton content={lastAgentContent} />}
<span className="inline-flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
{displayDuration}
</span>
</div>
);
@@ -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 (
<div className="flex justify-end w-full overflow-hidden">
<div className="max-w-[85%] sm:max-w-[75%] md:max-w-2xl overflow-hidden">
<div className="max-w-[85%] sm:max-w-[75%] md:max-w-2xl overflow-hidden group">
<div className="rounded-2xl bg-primary/30 px-4 py-2.5 text-xs overflow-hidden">
{/* Display image attachments */}
{hasAttachments && (
@@ -145,13 +169,32 @@ export const ChatMessage = memo(function ChatMessage({ comment, label, className
)}
{/* Display text content */}
{hasContent ? (
<p className="whitespace-pre-wrap break-words overflow-wrap-anywhere">
{renderContentWithFileRefs(comment.content)}
</p>
showRaw ? (
<pre className="whitespace-pre-wrap font-mono text-xs">
{comment.content}
</pre>
) : (
<p className="whitespace-pre-wrap break-words overflow-wrap-anywhere">
{renderContentWithFileRefs(comment.content)}
</p>
)
) : !hasAttachments ? (
<p className="whitespace-pre-wrap break-words overflow-wrap-anywhere">(empty)</p>
) : null}
</div>
<MessageActions
message={comment}
showCopy={true}
showTimestamp={true}
showRawToggle={true}
showNavigation={!!allMessages && allMessages.length > 0}
isRawView={showRaw}
onToggleRaw={() => setShowRaw(!showRaw)}
onNavigatePrev={handleNavigatePrev}
onNavigateNext={handleNavigateNext}
hasPrev={userNavigation.hasPrevious}
hasNext={userNavigation.hasNext}
/>
</div>
</div>
);
@@ -159,110 +202,130 @@ export const ChatMessage = memo(function ChatMessage({ comment, label, className
// Agent message: icon on left, no card background
return (
<div className="flex items-start gap-2 sm:gap-3 w-full">
<div className="flex items-start gap-2 sm:gap-3 w-full group">
{/* Content */}
<div className="flex-1 min-w-0 text-xs">
<div className="prose prose-sm dark:prose-invert max-w-none prose-p:my-4 prose-p:leading-relaxed prose-ul:my-4 prose-ul:list-disc prose-ul:pl-6 prose-ol:my-4 prose-ol:list-decimal prose-ol:pl-6 prose-li:my-1.5 prose-pre:my-5 prose-strong:text-foreground prose-strong:font-bold prose-headings:text-foreground prose-headings:font-bold">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
code: ({ className, children }) => {
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 <CodeBlock className={className}>{content}</CodeBlock>;
}
// Inline code (single backticks, single line)
return <InlineCode>{content}</InlineCode>;
},
ol: ({ children }) => (
<ol className="list-decimal pl-6 mb-2">
{children}
</ol>
),
ul: ({ children }) => (
<ul className="list-disc pl-6 mb-2">
{children}
</ul>
),
li: ({ children }) => (
<li className="my-0.5">
{children}
</li>
),
p: ({ children }) => (
<p className="leading-relaxed mb-1.5">
{children}
</p>
),
h1: ({ children }) => (
<p className="my-3 font-bold text-[13px]">
{children}
</p>
),
h2: ({ children }) => (
<p className="my-2 font-bold text-[13px]">
{children}
</p>
),
h3: ({ children }) => (
<p className="my-2 font-bold">
{children}
</p>
),
h4: ({ children }) => (
<p className="my-2 font-bold">
{children}
</p>
),
h5: ({ children }) => (
<p className="my-2 font-bold">
{children}
</p>
),
table: ({ children }) => (
<div className="my-3 overflow-x-auto">
<table className="border-collapse border border-border rounded-lg overflow-hidden">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-muted/50">
{children}
</thead>
),
tbody: ({ children }) => (
<tbody className="divide-y divide-border">
{children}
</tbody>
),
tr: ({ children }) => (
<tr className="border-b border-border last:border-b-0 hover:bg-muted/50">
{children}
</tr>
),
th: ({ children }) => (
<th className="px-3 py-2 text-left text-xs font-semibold text-foreground border-r border-border last:border-r-0">
{children}
</th>
),
td: ({ children }) => (
<td className="px-3 py-2 text-xs text-muted-foreground border-r border-border last:border-r-0">
{children}
</td>
),
}}
>
{showRaw ? (
<pre className="whitespace-pre-wrap font-mono text-xs bg-muted/20 p-3 rounded-md">
{comment.content || '(empty)'}
</ReactMarkdown>
{showRichBlocks ? <RichBlocks comment={comment} /> : null}
</div>
</pre>
) : (
<div className="prose prose-sm dark:prose-invert max-w-none prose-p:my-4 prose-p:leading-relaxed prose-ul:my-4 prose-ul:list-disc prose-ul:pl-6 prose-ol:my-4 prose-ol:list-decimal prose-ol:pl-6 prose-li:my-1.5 prose-pre:my-5 prose-strong:text-foreground prose-strong:font-bold prose-headings:text-foreground prose-headings:font-bold">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
code: ({ className, children }) => {
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 <CodeBlock className={className}>{content}</CodeBlock>;
}
// Inline code (single backticks, single line)
return <InlineCode>{content}</InlineCode>;
},
ol: ({ children }) => (
<ol className="list-decimal pl-6 mb-2">
{children}
</ol>
),
ul: ({ children }) => (
<ul className="list-disc pl-6 mb-2">
{children}
</ul>
),
li: ({ children }) => (
<li className="my-0.5">
{children}
</li>
),
p: ({ children }) => (
<p className="leading-relaxed mb-1.5">
{children}
</p>
),
h1: ({ children }) => (
<p className="my-3 font-bold text-sm">
{children}
</p>
),
h2: ({ children }) => (
<p className="my-2 font-bold text-sm">
{children}
</p>
),
h3: ({ children }) => (
<p className="my-2 font-bold text-sm">
{children}
</p>
),
h4: ({ children }) => (
<p className="my-2 font-bold">
{children}
</p>
),
h5: ({ children }) => (
<p className="my-2 font-bold">
{children}
</p>
),
hr: ({ children }) => (
<hr className="my-5">
{children}
</hr>
),
table: ({ children }) => (
<div className="my-3 overflow-x-auto">
<table className="border-collapse border border-border rounded-lg overflow-hidden">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-muted/50">
{children}
</thead>
),
tbody: ({ children }) => (
<tbody className="divide-y divide-border">
{children}
</tbody>
),
tr: ({ children }) => (
<tr className="border-b border-border last:border-b-0 hover:bg-muted/50">
{children}
</tr>
),
th: ({ children }) => (
<th className="px-3 py-2 text-left text-xs font-semibold text-foreground border-r border-border last:border-r-0">
{children}
</th>
),
td: ({ children }) => (
<td className="px-3 py-2 text-xs text-muted-foreground border-r border-border last:border-r-0">
{children}
</td>
),
}}
>
{comment.content || '(empty)'}
</ReactMarkdown>
{showRichBlocks ? <RichBlocks comment={comment} /> : null}
</div>
)}
<MessageActions
message={comment}
showCopy={true}
showTimestamp={true}
showRawToggle={true}
showNavigation={false}
isRawView={showRaw}
onToggleRaw={() => setShowRaw(!showRaw)}
/>
</div>
</div>
);
@@ -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 (
<div className="relative group my-4 w-fit max-w-full min-w-[50%]">
<div className="relative group/code-block my-4 w-fit max-w-full min-w-[50%]">
{/* Copy button */}
<button
onClick={handleCopy}
@@ -55,7 +49,7 @@ export function CodeBlock({ children, className }: CodeBlockProps) {
'p-1.5 rounded-md',
'bg-white/10 hover:bg-white/20',
'transition-all duration-200',
'opacity-0 group-hover:opacity-100',
'opacity-0 group-hover/code-block:opacity-100',
'cursor-pointer',
)}
title="Copy code"
@@ -32,7 +32,7 @@ export const ExpandableRow = memo(function ExpandableRow({
}, [hasExpandableContent, onToggle]);
return (
<div className="w-full group">
<div className="w-full group/expandable">
{/* Clickable header row */}
<div
className={cn(
@@ -46,16 +46,16 @@ export const ExpandableRow = memo(function ExpandableRow({
<div
className={cn(
'absolute inset-0 transition-opacity',
hasExpandableContent && 'group-hover:opacity-0'
hasExpandableContent && 'group-hover/expandable:opacity-0'
)}
>
{icon}
</div>
{hasExpandableContent &&
(isExpanded ? (
<IconChevronDown className="h-4 w-4 text-muted-foreground absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity" />
<IconChevronDown className="h-4 w-4 text-muted-foreground absolute inset-0 opacity-0 group-hover/expandable:opacity-100 transition-opacity" />
) : (
<IconChevronRight className="h-4 w-4 text-muted-foreground absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity" />
<IconChevronRight className="h-4 w-4 text-muted-foreground absolute inset-0 opacity-0 group-hover/expandable:opacity-100 transition-opacity" />
))}
</div>
@@ -2,33 +2,31 @@
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
type InlineCodeProps = {
children: React.ReactNode;
};
export function InlineCode({ children }: InlineCodeProps) {
const { copy } = useCopyToClipboard();
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipText, setTooltipText] = useState('Copy to clipboard');
const handleClick = async () => {
const text = String(children);
try {
await navigator.clipboard.writeText(text);
setTooltipText('Copied!');
setShowTooltip(true);
await copy(text);
setTooltipText('Copied!');
setShowTooltip(true);
setTimeout(() => {
setShowTooltip(false);
setTimeout(() => setTooltipText('Copy to clipboard'), 200);
}, 1500);
} catch (err) {
console.error('Failed to copy:', err);
}
setTimeout(() => {
setShowTooltip(false);
setTimeout(() => setTooltipText('Copy to clipboard'), 200);
}, 1500);
};
return (
<span className="relative inline-block group">
<span className="relative inline-block group/inline-code">
<code
onClick={handleClick}
className={cn(
@@ -45,7 +43,7 @@ export function InlineCode({ children }: InlineCodeProps) {
'absolute bottom-full left-1/2 -translate-x-1/2 mb-1',
'px-2 py-1 text-xs text-white bg-gray-900 rounded whitespace-nowrap',
'pointer-events-none transition-opacity duration-200',
'opacity-0 group-hover:opacity-100',
'opacity-0 group-hover/inline-code:opacity-100',
showTooltip && 'opacity-100'
)}
>
@@ -0,0 +1,124 @@
'use client';
import { IconCheck, IconCopy, IconCode, IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
import { formatRelativeTime } from '@/lib/utils';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import type { Message } from '@/lib/types/http';
type MessageActionsProps = {
message: Message;
showCopy?: boolean;
showTimestamp?: boolean;
showRawToggle?: boolean;
showNavigation?: boolean;
isRawView?: boolean;
onToggleRaw?: () => void;
onNavigatePrev?: () => void;
onNavigateNext?: () => void;
hasPrev?: boolean;
hasNext?: boolean;
};
export function MessageActions({
message,
showCopy = true,
showTimestamp = true,
showRawToggle = true,
showNavigation = false,
isRawView = false,
onToggleRaw,
onNavigatePrev,
onNavigateNext,
hasPrev = false,
hasNext = false,
}: MessageActionsProps) {
const { copied, copy } = useCopyToClipboard();
const handleCopy = async () => {
await copy(message.content);
};
return (
<div className="flex items-center gap-2 mt-2 opacity-0 group-hover:opacity-100 transition-opacity">
{/* Copy button */}
{showCopy && (
<button
onClick={handleCopy}
className={cn(
'h-5 w-5 p-1',
'hover:bg-muted rounded',
'transition-colors duration-200',
copied && 'text-green-400',
)}
title="Copy message"
aria-label="Copy message to clipboard"
>
{copied ? (
<IconCheck className="h-full w-full" />
) : (
<IconCopy className="h-full w-full" />
)}
</button>
)}
{/* Raw text toggle */}
{showRawToggle && onToggleRaw && (
<button
onClick={onToggleRaw}
className={cn(
'h-5 w-5 p-1',
'hover:bg-muted rounded',
'transition-colors duration-200',
isRawView && 'bg-muted text-foreground',
)}
title={isRawView ? 'Show formatted' : 'Show raw text'}
aria-label={isRawView ? 'Show formatted message' : 'Show raw text'}
>
<IconCode className="h-full w-full" />
</button>
)}
{/* Navigation buttons */}
{showNavigation && (
<>
<button
onClick={onNavigatePrev}
disabled={!hasPrev}
className={cn(
'h-5 w-5 p-1',
'hover:bg-muted rounded',
'transition-colors duration-200',
'disabled:opacity-30 disabled:cursor-not-allowed',
)}
title="Previous message"
aria-label="Go to previous message"
>
<IconChevronLeft className="h-full w-full" />
</button>
<button
onClick={onNavigateNext}
disabled={!hasNext}
className={cn(
'h-5 w-5 p-1',
'hover:bg-muted rounded',
'transition-colors duration-200',
'disabled:opacity-30 disabled:cursor-not-allowed',
)}
title="Next message"
aria-label="Go to next message"
>
<IconChevronRight className="h-full w-full" />
</button>
</>
)}
{/* Timestamp */}
{showTimestamp && (
<span className="text-[10px] text-muted-foreground/60 font-mono">
{formatRelativeTime(message.created_at)}
</span>
)}
</div>
);
}
@@ -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"
>
<IconExternalLink className="h-3.5 w-3.5" />
@@ -131,7 +131,7 @@ export const ToolEditMessage = memo(function ToolEditMessage({ comment, worktree
<button
type="button"
onClick={handleCopyPath}
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={copied ? 'Copied!' : 'Copy path'}
>
{copied ? (
@@ -21,6 +21,8 @@ type TurnGroupMessageProps = {
isLastGroup?: boolean;
/** Whether the turn is still active (agent is running) */
isTurnActive?: boolean;
allMessages?: Message[];
onScrollToMessage?: (messageId: string) => void;
};
function countMessageTypes(messages: Message[]): { toolCalls: number; subagents: number } {
@@ -124,6 +126,8 @@ export const TurnGroupMessage = memo(function TurnGroupMessage({
onOpenFile,
isLastGroup = false,
isTurnActive = false,
allMessages,
onScrollToMessage,
}: TurnGroupMessageProps) {
// Check if any tool in the group is still running
const isGroupRunning = hasRunningTool(group.messages);
@@ -187,6 +191,8 @@ export const TurnGroupMessage = memo(function TurnGroupMessage({
worktreePath={worktreePath}
sessionId={sessionId ?? undefined}
onOpenFile={onOpenFile}
allMessages={allMessages}
onScrollToMessage={onScrollToMessage}
/>
))}
</div>
@@ -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}
/>
) : (
<MessageRenderer
@@ -193,6 +208,8 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({
worktreePath={worktreePath}
sessionId={sessionId ?? undefined}
onOpenFile={onOpenFile}
allMessages={messages}
onScrollToMessage={handleScrollToMessage}
/>
)}
</div>
+17
View File
@@ -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 };
}
+35
View File
@@ -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,
};
}