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:
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user