Add copy button for assistant turns in agent stream

- Add TurnCopyButton component that copies all assistant messages from a turn
- Detect turn boundaries (before user message or end of stream when not running)
- Lazily compute turn content on click to avoid render-time overhead
- Ghost icon style with hover effect (color lightens)
This commit is contained in:
Mohamed Boudra
2026-01-13 12:06:33 +07:00
parent 7f7f5c53e6
commit 4803589ea1
2 changed files with 106 additions and 1 deletions

View File

@@ -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<StreamItem>) => {
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 (
<View
style={[
@@ -399,10 +430,11 @@ export function AgentStreamView({
]}
>
{content}
{isEndOfTurn ? <TurnCopyButton getContent={getContent} /> : null}
</View>
);
},
[getGapAbove, renderStreamItemContent]
[getGapAbove, renderStreamItemContent, flatListData, agent.status, collectTurnContent]
);
const pendingPermissionItems = useMemo(

View File

@@ -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<ReturnType<typeof setTimeout> | 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 (
<Pressable
onPress={handleCopy}
style={turnCopyButtonStylesheet.container}
accessibilityRole="button"
accessibilityLabel={copied ? "Copied" : "Copy turn"}
>
{({ hovered }) => {
const iconColor = hovered
? turnCopyButtonStylesheet.iconHoveredColor.color
: turnCopyButtonStylesheet.iconColor.color;
return copied ? (
<Check size={18} color={iconColor} />
) : (
<Copy size={18} color={iconColor} />
);
}}
</Pressable>
);
});
const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
container: {
marginHorizontal: theme.spacing[2],