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:
@@ -37,6 +37,7 @@ import {
|
|||||||
ToolCall,
|
ToolCall,
|
||||||
AgentThoughtMessage,
|
AgentThoughtMessage,
|
||||||
TodoListCard,
|
TodoListCard,
|
||||||
|
TurnCopyButton,
|
||||||
MessageOuterSpacingProvider,
|
MessageOuterSpacingProvider,
|
||||||
type InlinePathTarget,
|
type InlinePathTarget,
|
||||||
} from "./message";
|
} from "./message";
|
||||||
@@ -382,6 +383,26 @@ export function AgentStreamView({
|
|||||||
[handleInlinePathPress, agent.cwd, flatListData]
|
[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(
|
const renderStreamItem = useCallback(
|
||||||
({ item, index }: ListRenderItemInfo<StreamItem>) => {
|
({ item, index }: ListRenderItemInfo<StreamItem>) => {
|
||||||
const content = renderStreamItemContent(item, index);
|
const content = renderStreamItemContent(item, index);
|
||||||
@@ -391,6 +412,16 @@ export function AgentStreamView({
|
|||||||
|
|
||||||
const gap = getGapAbove(item, index);
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -399,10 +430,11 @@ export function AgentStreamView({
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
{isEndOfTurn ? <TurnCopyButton getContent={getContent} /> : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[getGapAbove, renderStreamItemContent]
|
[getGapAbove, renderStreamItemContent, flatListData, agent.status, collectTurnContent]
|
||||||
);
|
);
|
||||||
|
|
||||||
const pendingPermissionItems = useMemo(
|
const pendingPermissionItems = useMemo(
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
Search,
|
Search,
|
||||||
Brain,
|
Brain,
|
||||||
|
Copy,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import {
|
import {
|
||||||
StyleSheet,
|
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) => ({
|
const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
marginHorizontal: theme.spacing[2],
|
marginHorizontal: theme.spacing[2],
|
||||||
|
|||||||
Reference in New Issue
Block a user