feat: added message actions (#203)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user