diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index ba9f231ea..28e0c91cb 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -37,6 +37,7 @@ import { ToolCall, AgentThoughtMessage, TodoListCard, + TurnCopyButton, MessageOuterSpacingProvider, type InlinePathTarget, } from "./message"; @@ -382,6 +383,26 @@ export function AgentStreamView({ [handleInlinePathPress, agent.cwd, flatListData] ); + const collectTurnContent = useCallback( + (index: number) => { + const messages: string[] = []; + // Walk backwards (older items) from current index + // In inverted list: index+1 is the item above (older in time) + for (let i = index; i < flatListData.length; i++) { + const currentItem = flatListData[i]; + if (currentItem.kind === "user_message") { + break; + } + if (currentItem.kind === "assistant_message") { + messages.push(currentItem.text); + } + } + // Messages are collected newest-first, reverse for chronological order + return messages.reverse().join("\n\n"); + }, + [flatListData] + ); + const renderStreamItem = useCallback( ({ item, index }: ListRenderItemInfo) => { const content = renderStreamItemContent(item, index); @@ -391,6 +412,16 @@ export function AgentStreamView({ const gap = getGapAbove(item, index); + // Check if this is the end of a turn (before a user message or end of stream when not running) + // In inverted list: index-1 is the next item (newer in time) + const nextItem = flatListData[index - 1]; + const isEndOfTurn = + item.kind !== "user_message" && + (nextItem?.kind === "user_message" || + (nextItem === undefined && agent.status !== "running")); + + const getContent = () => collectTurnContent(index); + return ( {content} + {isEndOfTurn ? : null} ); }, - [getGapAbove, renderStreamItemContent] + [getGapAbove, renderStreamItemContent, flatListData, agent.status, collectTurnContent] ); const pendingPermissionItems = useMemo( diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index c3d60b009..7658f0055 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -30,6 +30,7 @@ import { SquareTerminal, Search, Brain, + Copy, } from "lucide-react-native"; import { StyleSheet, @@ -254,6 +255,78 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ }, })); +const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({ + container: { + alignSelf: "flex-start", + padding: theme.spacing[2], + marginLeft: theme.spacing[4], + }, + iconColor: { + color: theme.colors.mutedForeground, + }, + iconHoveredColor: { + color: theme.colors.foreground, + }, +})); + +interface TurnCopyButtonProps { + getContent: () => string; +} + +export const TurnCopyButton = memo(function TurnCopyButton({ + getContent, +}: TurnCopyButtonProps) { + const [copied, setCopied] = useState(false); + const copyTimeoutRef = useRef | null>(null); + + const handleCopy = useCallback(async () => { + const content = getContent(); + if (!content) { + return; + } + + await Clipboard.setStringAsync(content); + setCopied(true); + + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current); + } + + copyTimeoutRef.current = setTimeout(() => { + setCopied(false); + copyTimeoutRef.current = null; + }, 1500); + }, [getContent]); + + useEffect(() => { + return () => { + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current); + } + }; + }, []); + + return ( + + {({ hovered }) => { + const iconColor = hovered + ? turnCopyButtonStylesheet.iconHoveredColor.color + : turnCopyButtonStylesheet.iconColor.color; + return copied ? ( + + ) : ( + + ); + }} + + ); +}); + const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ container: { marginHorizontal: theme.spacing[2],