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();
const rewindSheet = page.getByTestId("rewind-menu-content");
await expect(rewindSheet).toBeVisible();
await expect(rewindSheet.getByText("Rewind to this message", { exact: true })).toBeVisible();
await expect(
rewindSheet.getByText("This action cannot be undone", { exact: true }),
).toBeVisible();
@@ -149,7 +148,6 @@ test.describe("Rewind sheet", () => {
await page.getByTestId("rewind-menu-trigger").first().click();
const rewindSheet = page.getByTestId("rewind-menu-content");
await expect(rewindSheet).toBeVisible();
await expect(rewindSheet.getByText("Rewind to this message", { exact: true })).toBeVisible();
await expect(
rewindSheet.getByText("This action cannot be undone", { exact: true }),
).toBeVisible();
+7 -1
View File
@@ -409,8 +409,14 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
copyButton: {
padding: theme.spacing[1],
alignSelf: "center",
width: 24,
height: 24,
padding: 0,
margin: 0,
marginRight: -theme.spacing[1],
alignItems: "center",
justifyContent: "center",
},
trailingRow: {
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 { FileText, Layers, MessageSquare, Undo2 } from "lucide-react-native";
import { StyleSheet } 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
type RewindMenuItem,
type RewindMode,
useRewindCapabilities,
} from "./use-rewind-capabilities";
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
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";
export type { RewindMode };
@@ -22,17 +23,14 @@ interface RewindMenuProps {
testID?: string;
}
const REWIND_HEADER: SheetHeader = { title: "Rewind to this message" };
const SNAP_POINTS: string[] = ["42%", "72%"];
function iconForItem(item: RewindMenuItem): ComponentType<{ color: string; size: number }> {
switch (item.mode) {
function getIcon(mode: RewindMode, color: string): ReactElement {
switch (mode) {
case "conversation":
return MessageSquare;
return <MessageSquare size={16} color={color} />;
case "files":
return FileText;
return <FileText size={16} color={color} />;
case "both":
return Layers;
return <Layers size={16} color={color} />;
}
}
@@ -40,36 +38,44 @@ export const RewindMenu = memo(function RewindMenu({
capabilities,
rewoundText,
onRewind,
isPending = false,
isPending: isPendingProp = false,
testID = "rewind-menu",
}: RewindMenuProps) {
const { theme } = useUnistyles();
const items = useRewindCapabilities(capabilities);
const [isSheetVisible, setIsSheetVisible] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [pendingMode, setPendingMode] = useState<RewindMode | null>(null);
const handleOpen = useCallback(() => setIsSheetVisible(true), []);
const handleClose = useCallback(() => {
if (!isPending) {
setIsSheetVisible(false);
}
}, [isPending]);
const isLocked = isPendingProp || pendingMode !== null;
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next && pendingMode !== null) return;
setIsOpen(next);
},
[pendingMode],
);
const handleSelect = useCallback(
(mode: RewindMode) => async () => {
if (isPending) {
return;
}
if (isLocked) return;
setPendingMode(mode);
try {
await onRewind({ mode, rewoundText });
setIsOpen(false);
} catch {
// useRewindAgentMutation owns the toast; the sheet only owns flow state.
// useRewindAgentMutation owns the toast; the menu only owns flow state.
} finally {
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(
() => (
<TooltipContent side="top" align="center" offset={8}>
@@ -84,53 +90,48 @@ export const RewindMenu = memo(function RewindMenu({
}
return (
<>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Button
accessibilityLabel="Rewind to this message"
disabled={isPending}
leftIcon={triggerIcon}
onPress={handleOpen}
size="xs"
style={styles.trigger}
testID={`${testID}-trigger`}
variant="ghost"
/>
<View style={styles.triggerSlot} collapsable={false}>
<DropdownMenuTrigger
accessibilityLabel="Rewind to this message"
accessibilityRole="button"
disabled={isLocked}
style={triggerStyle}
testID={`${testID}-trigger`}
>
{({ hovered, open }) => (
<Undo2
size={16}
color={hovered || open ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</DropdownMenuTrigger>
</View>
</TooltipTrigger>
{tooltipContent}
</Tooltip>
<AdaptiveModalSheet
desktopMaxWidth={420}
header={REWIND_HEADER}
onClose={handleClose}
scrollable={false}
snapPoints={SNAP_POINTS}
testID={`${testID}-content`}
visible={isSheetVisible}
>
<View style={styles.content}>
<DropdownMenuContent align="end" minWidth={220} side="bottom" testID={`${testID}-content`}>
<View style={styles.warningHeader}>
<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>
</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: {
width: 24,
height: 24,
minHeight: 24,
paddingHorizontal: 0,
paddingVertical: 0,
alignItems: "center",
justifyContent: "center",
backgroundColor: "transparent",
},
triggerDisabled: {
opacity: theme.opacity[50],
},
triggerSlot: {
alignSelf: "flex-start",
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
},
content: {
gap: theme.spacing[4],
warningHeader: {
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[2],
},
warningText: {
color: theme.colors.statusWarning,
fontSize: theme.fontSize.sm,
},
actions: {
gap: theme.spacing[2],
},
actionButton: {
justifyContent: "flex-start",
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
}));