Open rewind in an anchored dropdown menu

Replace the full-screen rewind sheet with a dropdown anchored to the
trigger, with a muted warning header and inline pending state on the
chosen action. Also realign the user message action row so timestamp,
rewind, and copy share a single 24px baseline — the copy button was
inheriting assistant-footer paddings that pushed the row taller than
the icons.
This commit is contained in:
Mohamed Boudra
2026-05-25 20:18:57 +07:00
parent f0730d5eac
commit e5658654c0
3 changed files with 94 additions and 89 deletions
@@ -68,7 +68,6 @@ test.describe("Rewind sheet", () => {
await page.getByTestId("rewind-menu-trigger").first().click(); await page.getByTestId("rewind-menu-trigger").first().click();
const rewindSheet = page.getByTestId("rewind-menu-content"); const rewindSheet = page.getByTestId("rewind-menu-content");
await expect(rewindSheet).toBeVisible(); await expect(rewindSheet).toBeVisible();
await expect(rewindSheet.getByText("Rewind to this message", { exact: true })).toBeVisible();
await expect( await expect(
rewindSheet.getByText("This action cannot be undone", { exact: true }), rewindSheet.getByText("This action cannot be undone", { exact: true }),
).toBeVisible(); ).toBeVisible();
@@ -149,7 +148,6 @@ test.describe("Rewind sheet", () => {
await page.getByTestId("rewind-menu-trigger").first().click(); await page.getByTestId("rewind-menu-trigger").first().click();
const rewindSheet = page.getByTestId("rewind-menu-content"); const rewindSheet = page.getByTestId("rewind-menu-content");
await expect(rewindSheet).toBeVisible(); await expect(rewindSheet).toBeVisible();
await expect(rewindSheet.getByText("Rewind to this message", { exact: true })).toBeVisible();
await expect( await expect(
rewindSheet.getByText("This action cannot be undone", { exact: true }), rewindSheet.getByText("This action cannot be undone", { exact: true }),
).toBeVisible(); ).toBeVisible();
+7 -1
View File
@@ -409,8 +409,14 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },
copyButton: { copyButton: {
padding: theme.spacing[1], alignSelf: "center",
width: 24,
height: 24,
padding: 0,
margin: 0,
marginRight: -theme.spacing[1], marginRight: -theme.spacing[1],
alignItems: "center",
justifyContent: "center",
}, },
trailingRow: { trailingRow: {
alignSelf: "flex-end", alignSelf: "flex-end",
@@ -1,15 +1,16 @@
import { memo, useCallback, useMemo, useState, type ComponentType } from "react"; import { memo, useCallback, useMemo, useState, type ReactElement } from "react";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { FileText, Layers, MessageSquare, Undo2 } from "lucide-react-native"; import { FileText, Layers, MessageSquare, Undo2 } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { import {
type RewindMenuItem, DropdownMenu,
type RewindMode, DropdownMenuContent,
useRewindCapabilities, DropdownMenuItem,
} from "./use-rewind-capabilities"; DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { type RewindMode, useRewindCapabilities } from "./use-rewind-capabilities";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types"; import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
export type { RewindMode }; export type { RewindMode };
@@ -22,17 +23,14 @@ interface RewindMenuProps {
testID?: string; testID?: string;
} }
const REWIND_HEADER: SheetHeader = { title: "Rewind to this message" }; function getIcon(mode: RewindMode, color: string): ReactElement {
const SNAP_POINTS: string[] = ["42%", "72%"]; switch (mode) {
function iconForItem(item: RewindMenuItem): ComponentType<{ color: string; size: number }> {
switch (item.mode) {
case "conversation": case "conversation":
return MessageSquare; return <MessageSquare size={16} color={color} />;
case "files": case "files":
return FileText; return <FileText size={16} color={color} />;
case "both": case "both":
return Layers; return <Layers size={16} color={color} />;
} }
} }
@@ -40,36 +38,44 @@ export const RewindMenu = memo(function RewindMenu({
capabilities, capabilities,
rewoundText, rewoundText,
onRewind, onRewind,
isPending = false, isPending: isPendingProp = false,
testID = "rewind-menu", testID = "rewind-menu",
}: RewindMenuProps) { }: RewindMenuProps) {
const { theme } = useUnistyles();
const items = useRewindCapabilities(capabilities); const items = useRewindCapabilities(capabilities);
const [isSheetVisible, setIsSheetVisible] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [pendingMode, setPendingMode] = useState<RewindMode | null>(null); const [pendingMode, setPendingMode] = useState<RewindMode | null>(null);
const handleOpen = useCallback(() => setIsSheetVisible(true), []); const isLocked = isPendingProp || pendingMode !== null;
const handleClose = useCallback(() => {
if (!isPending) { const handleOpenChange = useCallback(
setIsSheetVisible(false); (next: boolean) => {
} if (!next && pendingMode !== null) return;
}, [isPending]); setIsOpen(next);
},
[pendingMode],
);
const handleSelect = useCallback( const handleSelect = useCallback(
(mode: RewindMode) => async () => { (mode: RewindMode) => async () => {
if (isPending) { if (isLocked) return;
return;
}
setPendingMode(mode); setPendingMode(mode);
try { try {
await onRewind({ mode, rewoundText }); await onRewind({ mode, rewoundText });
setIsOpen(false);
} catch { } catch {
// useRewindAgentMutation owns the toast; the sheet only owns flow state. // useRewindAgentMutation owns the toast; the menu only owns flow state.
} finally { } finally {
setPendingMode(null); setPendingMode(null);
setIsSheetVisible(false);
} }
}, },
[isPending, onRewind, rewoundText], [isLocked, onRewind, rewoundText],
); );
const triggerIcon = useCallback((color: string) => <Undo2 size={16} color={color} />, []);
const triggerStyle = useCallback(
() => [styles.trigger, isLocked ? styles.triggerDisabled : null],
[isLocked],
);
const tooltipContent = useMemo( const tooltipContent = useMemo(
() => ( () => (
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
@@ -84,53 +90,48 @@ export const RewindMenu = memo(function RewindMenu({
} }
return ( return (
<> <DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <View style={styles.triggerSlot} collapsable={false}>
accessibilityLabel="Rewind to this message" <DropdownMenuTrigger
disabled={isPending} accessibilityLabel="Rewind to this message"
leftIcon={triggerIcon} accessibilityRole="button"
onPress={handleOpen} disabled={isLocked}
size="xs" style={triggerStyle}
style={styles.trigger} testID={`${testID}-trigger`}
testID={`${testID}-trigger`} >
variant="ghost" {({ hovered, open }) => (
/> <Undo2
size={16}
color={hovered || open ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</DropdownMenuTrigger>
</View>
</TooltipTrigger> </TooltipTrigger>
{tooltipContent} {tooltipContent}
</Tooltip> </Tooltip>
<AdaptiveModalSheet <DropdownMenuContent align="end" minWidth={220} side="bottom" testID={`${testID}-content`}>
desktopMaxWidth={420} <View style={styles.warningHeader}>
header={REWIND_HEADER}
onClose={handleClose}
scrollable={false}
snapPoints={SNAP_POINTS}
testID={`${testID}-content`}
visible={isSheetVisible}
>
<View style={styles.content}>
<Text style={styles.warningText}>This action cannot be undone</Text> <Text style={styles.warningText}>This action cannot be undone</Text>
<View style={styles.actions}>
{items.map((item) => (
<Button
key={item.mode}
leftIcon={iconForItem(item)}
loading={pendingMode === item.mode}
disabled={isPending}
onPress={handleSelect(item.mode)}
size="md"
style={styles.actionButton}
testID={item.testID}
variant="secondary"
>
{item.label}
</Button>
))}
</View>
</View> </View>
</AdaptiveModalSheet> <DropdownMenuSeparator />
</> {items.map((item) => (
<DropdownMenuItem
key={item.mode}
closeOnSelect={false}
disabled={isLocked && pendingMode !== item.mode}
leading={getIcon(item.mode, theme.colors.foreground)}
onSelect={handleSelect(item.mode)}
status={pendingMode === item.mode ? "pending" : undefined}
testID={item.testID}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
); );
}); });
@@ -138,27 +139,27 @@ const styles = StyleSheet.create((theme) => ({
trigger: { trigger: {
width: 24, width: 24,
height: 24, height: 24,
minHeight: 24,
paddingHorizontal: 0,
paddingVertical: 0,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
backgroundColor: "transparent",
},
triggerDisabled: {
opacity: theme.opacity[50],
},
triggerSlot: {
alignSelf: "flex-start",
}, },
tooltipText: { tooltipText: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
}, },
content: { warningHeader: {
gap: theme.spacing[4], paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[2],
}, },
warningText: { warningText: {
color: theme.colors.statusWarning, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.xs,
},
actions: {
gap: theme.spacing[2],
},
actionButton: {
justifyContent: "flex-start",
}, },
})); }));