app: restore per-turn copy actions and refine message copy UI

This commit is contained in:
Mohamed Boudra
2026-02-06 21:49:09 +07:00
parent 1413c63990
commit f2c88ae073
2 changed files with 61 additions and 85 deletions
@@ -396,14 +396,23 @@ export function AgentStreamView({
} }
const gapBelow = getGapBelow(item, index); const gapBelow = getGapBelow(item, index);
const nextItem = flatListData[index - 1];
const isEndOfAssistantTurn =
item.kind === "assistant_message" &&
(nextItem?.kind === "user_message" ||
(nextItem === undefined && agent.status !== "running"));
const getTurnContent = () => collectAssistantTurnContent(flatListData, index);
return ( return (
<View style={[stylesheet.streamItemWrapper, { marginBottom: gapBelow }]}> <View style={[stylesheet.streamItemWrapper, { marginBottom: gapBelow }]}>
{content} {content}
{isEndOfAssistantTurn ? (
<TurnCopyButton getContent={getTurnContent} />
) : null}
</View> </View>
); );
}, },
[getGapBelow, renderStreamItemContent] [getGapBelow, renderStreamItemContent, flatListData, agent.status]
); );
const pendingPermissionItems = useMemo( const pendingPermissionItems = useMemo(
@@ -483,18 +492,7 @@ export function AgentStreamView({
}, [agentId, pendingPermissionItems.length, streamHead, streamItems]); }, [agentId, pendingPermissionItems.length, streamHead, streamItems]);
const showWorkingIndicator = agent.status === "running"; const showWorkingIndicator = agent.status === "running";
const newestItem = flatListData[0] ?? null; const showBottomBar = showWorkingIndicator || isVoiceMode;
const latestTurnContent = useMemo(() => {
if (flatListData.length === 0) {
return "";
}
return collectAssistantTurnContent(flatListData, 0);
}, [flatListData]);
const showCopyButton =
agent.status !== "running" &&
newestItem?.kind === "assistant_message" &&
latestTurnContent.trim().length > 0;
const showBottomBar = showWorkingIndicator || showCopyButton || isVoiceMode;
const listHeaderComponent = useMemo(() => { const listHeaderComponent = useMemo(() => {
const hasPermissions = pendingPermissionItems.length > 0; const hasPermissions = pendingPermissionItems.length > 0;
@@ -504,11 +502,7 @@ export function AgentStreamView({
return null; return null;
} }
const leftContent = showWorkingIndicator ? ( const leftContent = showWorkingIndicator ? <WorkingIndicator /> : null;
<WorkingIndicator />
) : showCopyButton ? (
<TurnCopyButton getContent={() => latestTurnContent} />
) : null;
return ( return (
<View style={stylesheet.contentWrapper}> <View style={stylesheet.contentWrapper}>
@@ -564,8 +558,6 @@ export function AgentStreamView({
renderStreamItemContent, renderStreamItemContent,
tightGap, tightGap,
showBottomBar, showBottomBar,
showCopyButton,
latestTurnContent,
isVoiceMode, isVoiceMode,
]); ]);
@@ -574,14 +566,12 @@ export function AgentStreamView({
pendingPermissionCount: pendingPermissionItems.length, pendingPermissionCount: pendingPermissionItems.length,
showWorkingIndicator, showWorkingIndicator,
showBottomBar, showBottomBar,
showCopyButton,
isVoiceMode, isVoiceMode,
}), }),
[ [
pendingPermissionItems.length, pendingPermissionItems.length,
showWorkingIndicator, showWorkingIndicator,
showBottomBar, showBottomBar,
showCopyButton,
isVoiceMode, isVoiceMode,
] ]
); );
+49 -63
View File
@@ -99,6 +99,10 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
justifyContent: "flex-end", justifyContent: "flex-end",
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
}, },
content: {
alignItems: "flex-end",
maxWidth: "100%",
},
containerSpacing: { containerSpacing: {
marginBottom: theme.spacing[1], marginBottom: theme.spacing[1],
}, },
@@ -123,21 +127,16 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
lineHeight: 22, lineHeight: 22,
overflowWrap: "anywhere", overflowWrap: "anywhere",
}, },
bubblePressed: { copyButton: {
opacity: 0.85,
},
copiedTagContainer: {
marginTop: theme.spacing[1],
marginRight: theme.spacing[4],
alignSelf: "flex-end", alignSelf: "flex-end",
backgroundColor: theme.colors.surface2, padding: theme.spacing[1],
borderRadius: theme.borderRadius.base, marginTop: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingVertical: 4,
}, },
copiedTagText: { copyButtonHidden: {
color: theme.colors.foreground, opacity: 0,
fontSize: theme.fontSize.xs, },
copyButtonVisible: {
opacity: 1,
}, },
})); }));
@@ -150,34 +149,6 @@ export const UserMessage = memo(function UserMessage({
}: UserMessageProps) { }: UserMessageProps) {
const resolvedDisableOuterSpacing = const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing); useDisableOuterSpacing(disableOuterSpacing);
const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleLongPress = useCallback(async () => {
if (!message) {
return;
}
await Clipboard.setStringAsync(message);
setCopied(true);
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
copyTimeoutRef.current = setTimeout(() => {
setCopied(false);
copyTimeoutRef.current = null;
}, 1500);
}, [message]);
useEffect(() => {
return () => {
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
};
}, []);
return ( return (
<View <View
@@ -192,26 +163,30 @@ export const UserMessage = memo(function UserMessage({
], ],
]} ]}
> >
<Pressable <Pressable style={userMessageStylesheet.content}>
accessibilityRole="button" {({ hovered }) => {
accessibilityLabel="Copy message" const showCopyButton = Platform.OS !== "web" || hovered;
accessibilityHint="Long press to copy this message" return (
delayLongPress={250} <>
onLongPress={handleLongPress} <View style={userMessageStylesheet.bubble}>
style={({ pressed }) => [ <Text selectable style={userMessageStylesheet.text}>
userMessageStylesheet.bubble, {message}
pressed ? userMessageStylesheet.bubblePressed : null, </Text>
]} </View>
> <TurnCopyButton
<Text style={userMessageStylesheet.text}>{message}</Text> getContent={() => message}
containerStyle={[
userMessageStylesheet.copyButton,
showCopyButton
? userMessageStylesheet.copyButtonVisible
: userMessageStylesheet.copyButtonHidden,
]}
accessibilityLabel="Copy message"
/>
</>
);
}}
</Pressable> </Pressable>
{copied && (
<View style={userMessageStylesheet.copiedTagContainer}>
<Text style={userMessageStylesheet.copiedTagText}>
Copied to clipboard
</Text>
</View>
)}
</View> </View>
); );
}); });
@@ -225,7 +200,7 @@ interface AssistantMessageProps {
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
container: { container: {
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[3], paddingVertical: theme.spacing[3],
}, },
containerSpacing: { containerSpacing: {
@@ -261,6 +236,7 @@ const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
container: { container: {
alignSelf: "flex-start", alignSelf: "flex-start",
padding: theme.spacing[2], padding: theme.spacing[2],
paddingTop: 0,
}, },
iconColor: { iconColor: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
@@ -272,10 +248,16 @@ const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
interface TurnCopyButtonProps { interface TurnCopyButtonProps {
getContent: () => string; getContent: () => string;
containerStyle?: StyleProp<ViewStyle>;
accessibilityLabel?: string;
copiedAccessibilityLabel?: string;
} }
export const TurnCopyButton = memo(function TurnCopyButton({ export const TurnCopyButton = memo(function TurnCopyButton({
getContent, getContent,
containerStyle,
accessibilityLabel,
copiedAccessibilityLabel,
}: TurnCopyButtonProps) { }: TurnCopyButtonProps) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -310,9 +292,13 @@ export const TurnCopyButton = memo(function TurnCopyButton({
return ( return (
<Pressable <Pressable
onPress={handleCopy} onPress={handleCopy}
style={turnCopyButtonStylesheet.container} style={[turnCopyButtonStylesheet.container, containerStyle]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={copied ? "Copied" : "Copy turn"} accessibilityLabel={
copied
? (copiedAccessibilityLabel ?? "Copied")
: (accessibilityLabel ?? "Copy turn")
}
> >
{({ hovered }) => { {({ hovered }) => {
const iconColor = hovered const iconColor = hovered