diff --git a/src/features/messages/components/Messages.tsx b/src/features/messages/components/Messages.tsx index 6aa59bc3d..8e3150022 100644 --- a/src/features/messages/components/Messages.tsx +++ b/src/features/messages/components/Messages.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; import { Check, Copy } from "lucide-react"; import type { ConversationItem } from "../../../types"; import { Markdown } from "./Markdown"; @@ -24,6 +24,47 @@ type ToolSummary = { type StatusTone = "completed" | "processing" | "failed" | "unknown"; +type WorkingIndicatorProps = { + isThinking: boolean; + processingStartedAt?: number | null; + lastDurationMs?: number | null; + hasItems: boolean; +}; + +type MessageRowProps = { + item: Extract; + isCopied: boolean; + onCopy: (item: Extract) => void; + onOpenFileLink?: (path: string) => void; + onOpenFileLinkMenu?: (event: React.MouseEvent, path: string) => void; +}; + +type ReasoningRowProps = { + item: Extract; + isExpanded: boolean; + onToggle: (id: string) => void; + onOpenFileLink?: (path: string) => void; + onOpenFileLinkMenu?: (event: React.MouseEvent, path: string) => void; +}; + +type ReviewRowProps = { + item: Extract; + onOpenFileLink?: (path: string) => void; + onOpenFileLinkMenu?: (event: React.MouseEvent, path: string) => void; +}; + +type DiffRowProps = { + item: Extract; +}; + +type ToolRowProps = { + item: Extract; + isExpanded: boolean; + onToggle: (id: string) => void; + onOpenFileLink?: (path: string) => void; + onOpenFileLinkMenu?: (event: React.MouseEvent, path: string) => void; +}; + function basename(path: string) { if (!path) { return ""; @@ -150,6 +191,13 @@ function cleanCommandText(commandText: string) { return stripped.trim(); } +function formatDurationMs(durationMs: number) { + const durationSeconds = Math.max(0, Math.floor(durationMs / 1000)); + const durationMinutes = Math.floor(durationSeconds / 60); + const durationRemainder = durationSeconds % 60; + return `${durationMinutes}:${String(durationRemainder).padStart(2, "0")}`; +} + function statusToneFromText(status?: string): StatusTone { if (!status) { return "unknown"; @@ -204,6 +252,321 @@ function scrollKeyForItems(items: ConversationItem[]) { } } +const WorkingIndicator = memo(function WorkingIndicator({ + isThinking, + processingStartedAt = null, + lastDurationMs = null, + hasItems, +}: WorkingIndicatorProps) { + const [elapsedMs, setElapsedMs] = useState(0); + + useEffect(() => { + if (!isThinking || !processingStartedAt) { + setElapsedMs(0); + return undefined; + } + setElapsedMs(Date.now() - processingStartedAt); + const interval = window.setInterval(() => { + setElapsedMs(Date.now() - processingStartedAt); + }, 1000); + return () => window.clearInterval(interval); + }, [isThinking, processingStartedAt]); + + return ( + <> + {isThinking && ( +
+ +
+ {formatDurationMs(elapsedMs)} +
+ Working… +
+ )} + {!isThinking && lastDurationMs !== null && hasItems && ( +
+ + + Done in {formatDurationMs(lastDurationMs)} + + +
+ )} + + ); +}); + +const MessageRow = memo(function MessageRow({ + item, + isCopied, + onCopy, + onOpenFileLink, + onOpenFileLinkMenu, +}: MessageRowProps) { + return ( +
+
+ + +
+
+ ); +}); + +const ReasoningRow = memo(function ReasoningRow({ + item, + isExpanded, + onToggle, + onOpenFileLink, + onOpenFileLinkMenu, +}: ReasoningRowProps) { + const summaryText = item.summary || item.content; + const summaryLines = summaryText.split("\n"); + const trimmedLines = summaryLines.map((line) => line.trim()); + const titleLineIndex = trimmedLines.findIndex(Boolean); + const rawTitle = + titleLineIndex >= 0 ? trimmedLines[titleLineIndex] : "Reasoning"; + const cleanTitle = rawTitle + .replace(/[`*_~]/g, "") + .replace(/\[(.*?)\]\(.*?\)/g, "$1") + .trim(); + const summaryTitle = + cleanTitle.length > 80 + ? `${cleanTitle.slice(0, 80)}…` + : cleanTitle || "Reasoning"; + const reasoningTone: StatusTone = summaryText ? "completed" : "processing"; + const bodyText = + titleLineIndex >= 0 + ? summaryLines + .filter((_, index) => index !== titleLineIndex) + .join("\n") + .trim() + : ""; + const showReasoningBody = Boolean(bodyText); + return ( +
+ + {showReasoningBody && ( + + )} +
+ + ); +}); + +const ReviewRow = memo(function ReviewRow({ + item, + onOpenFileLink, + onOpenFileLinkMenu, +}: ReviewRowProps) { + const title = item.state === "started" ? "Review started" : "Review completed"; + return ( +
+
+ {title} + + Review + +
+ {item.text && ( + + )} +
+ ); +}); + +const DiffRow = memo(function DiffRow({ item }: DiffRowProps) { + return ( +
+
+ {item.title} + {item.status && {item.status}} +
+
+ +
+
+ ); +}); + +const ToolRow = memo(function ToolRow({ + item, + isExpanded, + onToggle, + onOpenFileLink, + onOpenFileLinkMenu, +}: ToolRowProps) { + const isFileChange = item.toolType === "fileChange"; + const isCommand = item.toolType === "commandExecution"; + const commandText = isCommand + ? item.title.replace(/^Command:\s*/i, "").trim() + : ""; + const summary = buildToolSummary(item, commandText); + const changeNames = (item.changes ?? []) + .map((change) => basename(change.path)) + .filter(Boolean); + const hasChanges = changeNames.length > 0; + const tone = toolStatusTone(item, hasChanges); + const summaryLabel = isFileChange + ? changeNames.length > 1 + ? "files edited" + : "file edited" + : isCommand + ? "" + : summary.label; + const summaryValue = isFileChange + ? changeNames.length > 1 + ? `${changeNames[0]} +${changeNames.length - 1}` + : changeNames[0] || "changes" + : summary.value; + const shouldFadeCommand = + isCommand && !isExpanded && (summaryValue?.length ?? 0) > 80; + const showToolOutput = isExpanded && (!isFileChange || !hasChanges); + return ( +
+ + {isExpanded && summary.detail && !isFileChange && ( +
{summary.detail}
+ )} + {isExpanded && isCommand && item.detail && ( +
+ cwd: {item.detail} +
+ )} + {isExpanded && isFileChange && hasChanges && ( +
+ {item.changes?.map((change, index) => ( +
+
+ {change.kind && ( + + {change.kind.toUpperCase()} + + )} + + {basename(change.path)} + +
+ {change.diff && ( +
+ +
+ )} +
+ ))} +
+ )} + {isExpanded && isFileChange && !hasChanges && item.detail && ( + + )} + {showToolOutput && summary.output && ( + + )} +
+ + ); +}); + export const Messages = memo(function Messages({ items, threadId, @@ -219,7 +582,6 @@ export const Messages = memo(function Messages({ const [expandedItems, setExpandedItems] = useState>(new Set()); const [copiedMessageId, setCopiedMessageId] = useState(null); const copyTimeoutRef = useRef(null); - const [elapsedMs, setElapsedMs] = useState(0); const scrollKey = scrollKeyForItems(items); const { openFileLink, showFileLinkMenu } = useFileLinkOpener(workspacePath); @@ -236,7 +598,7 @@ export const Messages = memo(function Messages({ useEffect(() => { autoScrollRef.current = true; }, [threadId]); - const toggleExpanded = (id: string) => { + const toggleExpanded = useCallback((id: string) => { setExpandedItems((prev) => { const next = new Set(prev); if (next.has(id)) { @@ -246,7 +608,7 @@ export const Messages = memo(function Messages({ } return next; }); - }; + }, []); const visibleItems = items; @@ -258,20 +620,23 @@ export const Messages = memo(function Messages({ }; }, []); - const handleCopyMessage = async (item: Extract) => { - try { - await navigator.clipboard.writeText(item.text); - setCopiedMessageId(item.id); - if (copyTimeoutRef.current) { - window.clearTimeout(copyTimeoutRef.current); + const handleCopyMessage = useCallback( + async (item: Extract) => { + try { + await navigator.clipboard.writeText(item.text); + setCopiedMessageId(item.id); + if (copyTimeoutRef.current) { + window.clearTimeout(copyTimeoutRef.current); + } + copyTimeoutRef.current = window.setTimeout(() => { + setCopiedMessageId(null); + }, 1200); + } catch { + // No-op: clipboard errors can occur in restricted contexts. } - copyTimeoutRef.current = window.setTimeout(() => { - setCopiedMessageId(null); - }, 1200); - } catch { - // No-op: clipboard errors can occur in restricted contexts. - } - }; + }, + [], + ); useEffect(() => { if (!bottomRef.current) { @@ -302,31 +667,6 @@ export const Messages = memo(function Messages({ }; }, [scrollKey, isThinking]); - useEffect(() => { - if (!isThinking || !processingStartedAt) { - setElapsedMs(0); - return undefined; - } - setElapsedMs(Date.now() - processingStartedAt); - const interval = window.setInterval(() => { - setElapsedMs(Date.now() - processingStartedAt); - }, 1000); - return () => window.clearInterval(interval); - }, [isThinking, processingStartedAt]); - - const elapsedSeconds = Math.max(0, Math.floor(elapsedMs / 1000)); - const elapsedMinutes = Math.floor(elapsedSeconds / 60); - const elapsedRemainder = elapsedSeconds % 60; - const formattedElapsed = `${elapsedMinutes}:${String(elapsedRemainder).padStart(2, "0")}`; - const lastDurationSeconds = lastDurationMs - ? Math.max(0, Math.floor(lastDurationMs / 1000)) - : 0; - const lastDurationMinutes = Math.floor(lastDurationSeconds / 60); - const lastDurationRemainder = lastDurationSeconds % 60; - const formattedLastDuration = `${lastDurationMinutes}:${String( - lastDurationRemainder, - ).padStart(2, "0")}`; - return (
-
- - -
-
+ ); } if (item.kind === "reasoning") { - const summaryText = item.summary || item.content; - const summaryLines = summaryText.split("\n"); - const trimmedLines = summaryLines.map((line) => line.trim()); - const titleLineIndex = trimmedLines.findIndex(Boolean); - const rawTitle = - titleLineIndex >= 0 ? trimmedLines[titleLineIndex] : "Reasoning"; - const cleanTitle = rawTitle - .replace(/[`*_~]/g, "") - .replace(/\[(.*?)\]\(.*?\)/g, "$1") - .trim(); - const summaryTitle = - cleanTitle.length > 80 - ? `${cleanTitle.slice(0, 80)}…` - : cleanTitle || "Reasoning"; - const reasoningTone: StatusTone = summaryText ? "completed" : "processing"; const isExpanded = expandedItems.has(item.id); - const bodyText = - titleLineIndex >= 0 - ? summaryLines - .filter((_, index) => index !== titleLineIndex) - .join("\n") - .trim() - : ""; - const showReasoningBody = Boolean(bodyText); return ( -
- - {showReasoningBody && ( - - )} -
- + ); } if (item.kind === "review") { - const title = - item.state === "started" ? "Review started" : "Review completed"; return ( -
-
- {title} - - Review - -
- {item.text && ( - - )} -
+ ); } if (item.kind === "diff") { - return ( -
-
- {item.title} - {item.status && {item.status}} -
-
- -
-
- ); + return ; } if (item.kind === "tool") { - const isFileChange = item.toolType === "fileChange"; - const isCommand = item.toolType === "commandExecution"; - const commandText = isCommand - ? item.title.replace(/^Command:\s*/i, "").trim() - : ""; - const summary = buildToolSummary(item, commandText); - const changeNames = (item.changes ?? []) - .map((change) => basename(change.path)) - .filter(Boolean); - const hasChanges = changeNames.length > 0; - const tone = toolStatusTone(item, hasChanges); const isExpanded = expandedItems.has(item.id); - const summaryLabel = isFileChange - ? changeNames.length > 1 - ? "files edited" - : "file edited" - : isCommand - ? "" - : summary.label; - const summaryValue = isFileChange - ? changeNames.length > 1 - ? `${changeNames[0]} +${changeNames.length - 1}` - : changeNames[0] || "changes" - : summary.value; - const shouldFadeCommand = - isCommand && !isExpanded && (summaryValue?.length ?? 0) > 80; - const showToolOutput = isExpanded && (!isFileChange || !hasChanges); return ( -
- - {isExpanded && summary.detail && !isFileChange && ( -
- {summary.detail} -
- )} - {isExpanded && isCommand && item.detail && ( -
- cwd: {item.detail} -
- )} - {isExpanded && isFileChange && hasChanges && ( -
- {item.changes?.map((change, index) => ( -
-
- {change.kind && ( - - {change.kind.toUpperCase()} - - )} - - {basename(change.path)} - -
- {change.diff && ( -
- -
- )} -
- ))} -
- )} - {isExpanded && isFileChange && !hasChanges && item.detail && ( - - )} - {showToolOutput && summary.output && ( - - )} -
- + item={item} + isExpanded={isExpanded} + onToggle={toggleExpanded} + onOpenFileLink={openFileLink} + onOpenFileLinkMenu={showFileLinkMenu} + /> ); } return null; })} - {isThinking && ( -
- -
- {formattedElapsed} -
- Working… -
- )} - {!isThinking && lastDurationMs !== null && items.length > 0 && ( -
- - - Done in {formattedLastDuration} - - -
- )} + 0} + /> {!items.length && (
Start a thread and send a prompt to the agent.