fix: checkpoint restore popover positioning issue (#8219) (#8220)

Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com>
This commit is contained in:
roomote[bot] 2025-09-23 18:35:46 -04:00 • committed by GitHub
parent 0682629ac8
commit 35791d03d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 264 additions and 48 deletions

View file

@ -8,16 +8,32 @@ import { useRooPortal } from "@/components/ui/hooks"
import { vscode } from "@src/utils/vscode"
import { Checkpoint } from "./schema"
type CheckpointMenuProps = {
type CheckpointMenuBaseProps = {
ts: number
commitHash: string
currentHash?: string
checkpoint: Checkpoint
}
type CheckpointMenuControlledProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
type CheckpointMenuUncontrolledProps = {
open?: undefined
onOpenChange?: undefined
}
type CheckpointMenuProps = CheckpointMenuBaseProps & (CheckpointMenuControlledProps | CheckpointMenuUncontrolledProps)
export const CheckpointMenu = ({ ts, commitHash, currentHash, checkpoint }: CheckpointMenuProps) => {
export const CheckpointMenu = ({
ts,
commitHash,
currentHash,
checkpoint,
open,
onOpenChange,
}: CheckpointMenuProps) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const [internalOpen, setInternalOpen] = useState(false)
const [isConfirming, setIsConfirming] = useState(false)
const portalContainer = useRooPortal("roo-portal")
@ -25,6 +41,9 @@ export const CheckpointMenu = ({ ts, commitHash, currentHash, checkpoint }: Chec
const previousCommitHash = checkpoint?.from
const isOpen = open ?? internalOpen
const setOpen = onOpenChange ?? setInternalOpen
const onCheckpointDiff = useCallback(() => {
vscode.postMessage({
type: "checkpointDiff",
@ -34,13 +53,23 @@ export const CheckpointMenu = ({ ts, commitHash, currentHash, checkpoint }: Chec
const onPreview = useCallback(() => {
vscode.postMessage({ type: "checkpointRestore", payload: { ts, commitHash, mode: "preview" } })
setIsOpen(false)
}, [ts, commitHash])
setOpen(false)
}, [ts, commitHash, setOpen])
const onRestore = useCallback(() => {
vscode.postMessage({ type: "checkpointRestore", payload: { ts, commitHash, mode: "restore" } })
setIsOpen(false)
}, [ts, commitHash])
setOpen(false)
}, [ts, commitHash, setOpen])
const handleOpenChange = useCallback(
(open: boolean) => {
setOpen(open)
if (!open) {
setIsConfirming(false)
}
},
[setOpen],
)
return (
<div className="flex flex-row gap-1">
@ -49,15 +78,10 @@ export const CheckpointMenu = ({ ts, commitHash, currentHash, checkpoint }: Chec
<span className="codicon codicon-diff-single" />
</Button>
</StandardTooltip>
<Popover
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open)
setIsConfirming(false)
}}>
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<StandardTooltip content={t("chat:checkpoint.menu.restore")}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon">
<Button variant="ghost" size="icon" aria-label={t("chat:checkpoint.menu.restore")}>
<span className="codicon codicon-history" />
</Button>
</PopoverTrigger>
@ -66,7 +90,7 @@ export const CheckpointMenu = ({ ts, commitHash, currentHash, checkpoint }: Chec
<div className="flex flex-col gap-2">
{!isCurrent && (
<div className="flex flex-col gap-1 group hover:text-foreground">
<Button variant="secondary" onClick={onPreview}>
<Button variant="secondary" onClick={onPreview} data-testid="restore-files-btn">
{t("chat:checkpoint.menu.restoreFiles")}
</Button>
<div className="text-muted transition-colors group-hover:text-foreground">
@ -74,39 +98,50 @@ export const CheckpointMenu = ({ ts, commitHash, currentHash, checkpoint }: Chec
</div>
</div>
)}
<div className="flex flex-col gap-1 group hover:text-foreground">
{!isCurrent && (
<div className="flex flex-col gap-1 group hover:text-foreground">
{!isConfirming ? (
<Button variant="secondary" onClick={() => setIsConfirming(true)}>
{t("chat:checkpoint.menu.restoreFilesAndTask")}
</Button>
) : (
<>
<Button variant="default" onClick={onRestore} className="grow">
<div className="flex flex-row gap-1">
<CheckIcon />
<div>{t("chat:checkpoint.menu.confirm")}</div>
</div>
<div className="flex flex-col gap-1 group hover:text-foreground">
{!isConfirming ? (
<Button
variant="secondary"
onClick={() => setIsConfirming(true)}
data-testid="restore-files-and-task-btn">
{t("chat:checkpoint.menu.restoreFilesAndTask")}
</Button>
<Button variant="secondary" onClick={() => setIsConfirming(false)}>
<div className="flex flex-row gap-1">
<Cross2Icon />
<div>{t("chat:checkpoint.menu.cancel")}</div>
</div>
</Button>
</>
)}
{isConfirming ? (
<div className="text-destructive font-bold">
{t("chat:checkpoint.menu.cannotUndo")}
</div>
) : (
<div className="text-muted transition-colors group-hover:text-foreground">
{t("chat:checkpoint.menu.restoreFilesAndTaskDescription")}
</div>
)}
) : (
<>
<Button
variant="default"
onClick={onRestore}
className="grow"
data-testid="confirm-restore-btn">
<div className="flex flex-row gap-1">
<CheckIcon />
<div>{t("chat:checkpoint.menu.confirm")}</div>
</div>
</Button>
<Button variant="secondary" onClick={() => setIsConfirming(false)}>
<div className="flex flex-row gap-1">
<Cross2Icon />
<div>{t("chat:checkpoint.menu.cancel")}</div>
</div>
</Button>
</>
)}
{isConfirming ? (
<div
data-testid="checkpoint-confirm-warning"
className="text-destructive font-bold">
{t("chat:checkpoint.menu.cannotUndo")}
</div>
) : (
<div className="text-muted transition-colors group-hover:text-foreground">
{t("chat:checkpoint.menu.restoreFilesAndTaskDescription")}
</div>
)}
</div>
</div>
</div>
)}
</div>
</PopoverContent>
</Popover>

View file

@ -1,5 +1,6 @@
import { useMemo } from "react"
import { useMemo, useRef, useState, useEffect } from "react"
import { useTranslation } from "react-i18next"
import { cn } from "@/lib/utils"
import { CheckpointMenu } from "./CheckpointMenu"
import { checkpointSchema } from "./schema"
@ -15,6 +16,37 @@ type CheckpointSavedProps = {
export const CheckpointSaved = ({ checkpoint, ...props }: CheckpointSavedProps) => {
const { t } = useTranslation()
const isCurrent = props.currentHash === props.commitHash
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const [isClosing, setIsClosing] = useState(false)
const closeTimer = useRef<number | null>(null)
useEffect(() => {
return () => {
if (closeTimer.current) {
window.clearTimeout(closeTimer.current)
closeTimer.current = null
}
}
}, [])
const handlePopoverOpenChange = (open: boolean) => {
setIsPopoverOpen(open)
if (open) {
setIsClosing(false)
if (closeTimer.current) {
window.clearTimeout(closeTimer.current)
closeTimer.current = null
}
} else {
setIsClosing(true)
closeTimer.current = window.setTimeout(() => {
setIsClosing(false)
closeTimer.current = null
}, 200) // keep menu visible briefly to avoid popover jump
}
}
const menuVisible = isPopoverOpen || isClosing
const metadata = useMemo(() => {
if (!checkpoint) {
@ -48,8 +80,16 @@ export const CheckpointSaved = ({ checkpoint, ...props }: CheckpointSavedProps)
"linear-gradient(90deg, rgba(0, 188, 255, .65), rgba(0, 188, 255, .65) 80%, rgba(0, 188, 255, 0) 99%)",
}}></span>
<div className="hidden group-hover:block h-4 -mt-2">
<CheckpointMenu {...props} checkpoint={metadata} />
{/* Keep menu visible while popover is open or briefly after close to prevent jump */}
<div
data-testid="checkpoint-menu-container"
className={cn("h-4 -mt-2", menuVisible ? "block" : "hidden group-hover:block")}>
<CheckpointMenu
{...props}
checkpoint={metadata}
open={isPopoverOpen}
onOpenChange={handlePopoverOpenChange}
/>
</div>
</div>
)

View file

@ -0,0 +1,141 @@
// npx vitest run src/components/chat/checkpoints/__tests__/CheckpointSaved.spec.tsx
vi.mock("@/components/ui", () => {
// Minimal UI primitives to ensure deterministic behavior in tests
return {
Button: ({ children, ...rest }: any) => <button {...rest}>{children}</button>,
StandardTooltip: ({ children }: any) => <>{children}</>,
Popover: ({ children, onOpenChange, open }: any) => {
lastOnOpenChange = onOpenChange
return (
<div data-testid="popover-root" data-open={open}>
{children}
</div>
)
},
PopoverTrigger: ({ children }: any) => <div data-testid="popover-trigger">{children}</div>,
PopoverContent: ({ children }: any) => <div data-testid="popover-content">{children}</div>,
}
})
import { render, waitFor, screen } from "@/utils/test-utils"
import React from "react"
import userEvent from "@testing-library/user-event"
import { CheckpointSaved } from "../CheckpointSaved"
// Capture onOpenChange from Popover to control open/close in tests
let lastOnOpenChange: ((open: boolean) => void) | undefined
const waitForOpenHandler = async () => {
await waitFor(() => {
// ensure Popover mock captured the onOpenChange handler before using it
expect(lastOnOpenChange).toBeTruthy()
})
}
describe("CheckpointSaved popover visibility", () => {
// Timers are controlled per-test to avoid interfering with i18n init
const baseProps = {
ts: 123,
commitHash: "abc123",
currentHash: "zzz999",
checkpoint: { from: "prev123", to: "abc123" } as Record<string, unknown>,
}
it("shows menu while popover is open and hides when closed", async () => {
const { getByTestId } = render(<CheckpointSaved {...baseProps} />)
const getMenu = () => getByTestId("checkpoint-menu-container") as HTMLElement
// Initially hidden (relies on group-hover)
expect(getMenu()).toBeTruthy()
expect(getMenu().className).toContain("hidden")
// Open via captured handler
await waitForOpenHandler()
lastOnOpenChange?.(true)
await waitFor(() => {
expect(getMenu().className).toContain("block")
expect(getMenu().className).not.toContain("hidden")
})
// Close via captured handler — menu remains visible briefly, then hides
lastOnOpenChange?.(false)
await waitFor(() => {
expect(getMenu().className).toContain("block")
})
await waitFor(() => {
expect(getMenu().className).toContain("hidden")
})
})
it("resets confirm state when popover closes", async () => {
const { getByTestId } = render(<CheckpointSaved {...baseProps} />)
// Open the popover
await waitForOpenHandler()
lastOnOpenChange?.(true)
// Enter confirm state
const restoreFilesAndTaskBtn = await waitFor(() => getByTestId("restore-files-and-task-btn"))
await userEvent.click(restoreFilesAndTaskBtn)
// Confirm warning should be visible
expect(getByTestId("checkpoint-confirm-warning")).toBeTruthy()
// Close popover -> confirm state should reset
lastOnOpenChange?.(false)
// Reopen
lastOnOpenChange?.(true)
// Confirm warning should be gone after reopening
await waitFor(() => {
expect(screen.queryByTestId("checkpoint-confirm-warning")).toBeNull()
})
})
it("closes popover after preview and after confirm restore", async () => {
const { getByTestId } = render(<CheckpointSaved {...baseProps} />)
const popoverRoot = () => getByTestId("popover-root")
const menuContainer = () => getByTestId("checkpoint-menu-container")
// Open
await waitForOpenHandler()
lastOnOpenChange?.(true)
await waitFor(() => {
expect(popoverRoot().getAttribute("data-open")).toBe("true")
expect(menuContainer().className).toContain("block")
})
// Click preview -> popover closes; menu remains briefly visible, then hides
await userEvent.click(getByTestId("restore-files-btn"))
await waitFor(() => {
expect(popoverRoot().getAttribute("data-open")).toBe("false")
expect(menuContainer().className).toContain("block")
})
await waitFor(() => {
expect(menuContainer().className).toContain("hidden")
})
// Reopen
lastOnOpenChange?.(true)
await waitFor(() => {
expect(popoverRoot().getAttribute("data-open")).toBe("true")
})
// Enter confirm and confirm restore -> popover closes; menu then hides
await userEvent.click(getByTestId("restore-files-and-task-btn"))
await userEvent.click(getByTestId("confirm-restore-btn"))
await waitFor(() => {
expect(popoverRoot().getAttribute("data-open")).toBe("false")
})
await waitFor(() => {
expect(menuContainer().className).toContain("hidden")
})
})
})