Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 80 additions & 45 deletions webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,42 @@ 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")

const isCurrent = currentHash === commitHash

const previousCommitHash = checkpoint?.from

const isOpen = open ?? internalOpen
const setOpen = onOpenChange ?? setInternalOpen

const onCheckpointDiff = useCallback(() => {
vscode.postMessage({
type: "checkpointDiff",
Expand All @@ -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">
Expand All @@ -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>
Expand All @@ -66,47 +90,58 @@ 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">
{t("chat:checkpoint.menu.restoreFilesDescription")}
</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>
</Button>
<Button variant="secondary" onClick={() => setIsConfirming(false)}>
<div className="flex flex-row gap-1">
<Cross2Icon />
<div>{t("chat:checkpoint.menu.cancel")}</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>
</>
)}
{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>
Expand Down
46 changes: 43 additions & 3 deletions webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage: Consider adding tests for this component, especially to verify the popover positioning fix and state management behavior. This would help prevent regression of the positioning bug.

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) {
Expand Down Expand Up @@ -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>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
})
})
})
Loading