mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: add initial checkpoint as state-driven UI element
Show the initial checkpoint (workspace state at task start) as a fixed UI element at the top of the chat, with visual states for pending/ready/failed. This replaces the previous approach of a header button and provides better UX: - Pending state: greyed out with spinner while initializing - Ready state: full color with restore/diff menu options - Failed state: warning styling with error indication Changes: - Add initialCheckpointState and initialCheckpointHash to extension state - New InitialCheckpoint component with three visual states - CheckpointMenu now supports isInitial prop to hide irrelevant options - Backend emits state transitions instead of messages
This commit is contained in:
parent
0c53f1937a
commit
8a433c6e1f
11 changed files with 523 additions and 17 deletions
|
|
@ -60,6 +60,7 @@ export interface ExtensionMessage {
|
|||
| "deleteCustomModeCheck"
|
||||
| "currentCheckpointUpdated"
|
||||
| "checkpointInitWarning"
|
||||
| "initialCheckpointState"
|
||||
| "browserToolEnabled"
|
||||
| "browserConnectionResult"
|
||||
| "remoteBrowserEnabled"
|
||||
|
|
@ -116,6 +117,9 @@ export interface ExtensionMessage {
|
|||
type: "WAIT_TIMEOUT" | "INIT_TIMEOUT"
|
||||
timeout: number
|
||||
}
|
||||
// Initial checkpoint state for state-driven UI
|
||||
initialCheckpointState?: "pending" | "ready" | "failed" | null
|
||||
initialCheckpointHash?: string | null
|
||||
action?:
|
||||
| "chatButtonClicked"
|
||||
| "settingsButtonClicked"
|
||||
|
|
@ -408,6 +412,10 @@ export type ExtensionState = Pick<
|
|||
featureRoomoteControlEnabled: boolean
|
||||
openAiCodexIsAuthenticated?: boolean
|
||||
debug?: boolean
|
||||
|
||||
// Initial checkpoint state for state-driven UI
|
||||
initialCheckpointState?: "pending" | "ready" | "failed" | null
|
||||
initialCheckpointHash?: string | null
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ function sendCheckpointInitWarn(task: Task, type?: "WAIT_TIMEOUT" | "INIT_TIMEOU
|
|||
})
|
||||
}
|
||||
|
||||
function sendInitialCheckpointState(task: Task, state: "pending" | "ready" | "failed" | null, hash?: string) {
|
||||
task.providerRef.deref()?.postMessageToWebview({
|
||||
type: "initialCheckpointState",
|
||||
initialCheckpointState: state,
|
||||
initialCheckpointHash: hash ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCheckpointService(task: Task, { interval = 250 }: { interval?: number } = {}) {
|
||||
if (!task.enableCheckpoints) {
|
||||
return undefined
|
||||
|
|
@ -98,6 +106,7 @@ export async function getCheckpointService(task: Task, { interval = 250 }: { int
|
|||
)
|
||||
if (!task?.checkpointService) {
|
||||
sendCheckpointInitWarn(task, "INIT_TIMEOUT", task.checkpointTimeout)
|
||||
sendInitialCheckpointState(task, "failed")
|
||||
task.enableCheckpoints = false
|
||||
return undefined
|
||||
} else {
|
||||
|
|
@ -121,6 +130,7 @@ export async function getCheckpointService(task: Task, { interval = 250 }: { int
|
|||
} catch (err) {
|
||||
if (err.name === "TimeoutError" && task.enableCheckpoints) {
|
||||
sendCheckpointInitWarn(task, "INIT_TIMEOUT", task.checkpointTimeout)
|
||||
sendInitialCheckpointState(task, "failed")
|
||||
}
|
||||
log(`[Task#getCheckpointService] ${err.message}`)
|
||||
task.enableCheckpoints = false
|
||||
|
|
@ -140,6 +150,7 @@ async function checkGitInstallation(
|
|||
|
||||
if (!gitInstalled) {
|
||||
log("[Task#getCheckpointService] Git is not installed, disabling checkpoints")
|
||||
sendInitialCheckpointState(task, "failed")
|
||||
task.enableCheckpoints = false
|
||||
task.checkpointServiceInitializing = false
|
||||
|
||||
|
|
@ -157,9 +168,18 @@ async function checkGitInstallation(
|
|||
}
|
||||
|
||||
// Git is installed, proceed with initialization
|
||||
service.on("initialize", () => {
|
||||
service.on("initialize", ({ baseHash }) => {
|
||||
log("[Task#getCheckpointService] service initialized")
|
||||
task.checkpointServiceInitializing = false
|
||||
|
||||
// Send initial checkpoint state as ready with the hash
|
||||
sendInitialCheckpointState(task, "ready", baseHash)
|
||||
|
||||
// Update webview with initial checkpoint hash (for currentCheckpoint tracking)
|
||||
provider?.postMessageToWebview({
|
||||
type: "currentCheckpointUpdated",
|
||||
text: baseHash,
|
||||
})
|
||||
})
|
||||
|
||||
service.on("checkpoint", ({ fromHash: from, toHash: to, suppressMessage }) => {
|
||||
|
|
@ -195,15 +215,20 @@ async function checkGitInstallation(
|
|||
|
||||
log("[Task#getCheckpointService] initializing shadow git")
|
||||
|
||||
// Set pending state before starting initialization
|
||||
sendInitialCheckpointState(task, "pending")
|
||||
|
||||
try {
|
||||
await service.initShadowGit()
|
||||
} catch (err) {
|
||||
log(`[Task#getCheckpointService] initShadowGit -> ${err.message}`)
|
||||
sendInitialCheckpointState(task, "failed")
|
||||
task.enableCheckpoints = false
|
||||
}
|
||||
} catch (err) {
|
||||
log(`[Task#getCheckpointService] Unexpected error during Git check: ${err.message}`)
|
||||
console.error("Git check error:", err)
|
||||
sendInitialCheckpointState(task, "failed")
|
||||
task.enableCheckpoints = false
|
||||
task.checkpointServiceInitializing = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import TaskHeader from "./TaskHeader"
|
|||
import SystemPromptWarning from "./SystemPromptWarning"
|
||||
import ProfileViolationWarning from "./ProfileViolationWarning"
|
||||
import { CheckpointWarning } from "./CheckpointWarning"
|
||||
import { InitialCheckpoint } from "./checkpoints/InitialCheckpoint"
|
||||
import { QueuedMessages } from "./QueuedMessages"
|
||||
import { WorktreeSelector } from "./WorktreeSelector"
|
||||
import DismissibleUpsell from "../common/DismissibleUpsell"
|
||||
|
|
@ -97,6 +98,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
messageQueue = [],
|
||||
isBrowserSessionActive,
|
||||
showWorktreesInHomeScreen,
|
||||
initialCheckpointState,
|
||||
initialCheckpointHash,
|
||||
enableCheckpoints,
|
||||
} = useExtensionState()
|
||||
|
||||
const messagesRef = useRef(messages)
|
||||
|
|
@ -1510,6 +1514,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
<CheckpointWarning warning={checkpointWarning} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Initial Checkpoint UI - state-driven element */}
|
||||
{initialCheckpointState && enableCheckpoints && (
|
||||
<div className="px-3">
|
||||
<InitialCheckpoint state={initialCheckpointState} hash={initialCheckpointHash} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col h-full justify-center p-6 min-h-0 overflow-y-auto gap-4 relative">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ type CheckpointMenuBaseProps = {
|
|||
ts: number
|
||||
commitHash: string
|
||||
checkpoint: Checkpoint
|
||||
isInitial?: boolean
|
||||
}
|
||||
type CheckpointMenuControlledProps = {
|
||||
onOpenChange: (open: boolean) => void
|
||||
|
|
@ -21,7 +22,7 @@ type CheckpointMenuUncontrolledProps = {
|
|||
}
|
||||
type CheckpointMenuProps = CheckpointMenuBaseProps & (CheckpointMenuControlledProps | CheckpointMenuUncontrolledProps)
|
||||
|
||||
export const CheckpointMenu = ({ ts, commitHash, checkpoint, onOpenChange }: CheckpointMenuProps) => {
|
||||
export const CheckpointMenu = ({ ts, commitHash, checkpoint, isInitial, onOpenChange }: CheckpointMenuProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [internalRestoreOpen, setInternalRestoreOpen] = useState(false)
|
||||
const [restoreConfirming, setRestoreConfirming] = useState(false)
|
||||
|
|
@ -95,11 +96,14 @@ export const CheckpointMenu = ({ ts, commitHash, checkpoint, onOpenChange }: Che
|
|||
|
||||
return (
|
||||
<div className="flex flex-row gap-1">
|
||||
<StandardTooltip content={t("chat:checkpoint.menu.viewDiff")}>
|
||||
<Button variant="ghost" size="icon" onClick={onCheckpointDiff}>
|
||||
<span className="codicon codicon-diff-single" />
|
||||
</Button>
|
||||
</StandardTooltip>
|
||||
{/* Hide "View Diff" for initial checkpoint - no previous checkpoint to diff against */}
|
||||
{!isInitial && (
|
||||
<StandardTooltip content={t("chat:checkpoint.menu.viewDiff")}>
|
||||
<Button variant="ghost" size="icon" onClick={onCheckpointDiff}>
|
||||
<span className="codicon codicon-diff-single" />
|
||||
</Button>
|
||||
</StandardTooltip>
|
||||
)}
|
||||
<Popover
|
||||
open={restoreOpen}
|
||||
onOpenChange={(open) => {
|
||||
|
|
@ -175,15 +179,18 @@ export const CheckpointMenu = ({ ts, commitHash, checkpoint, onOpenChange }: Che
|
|||
</StandardTooltip>
|
||||
<PopoverContent align="end" container={portalContainer} className="w-auto min-w-max">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
onDiffFromInit()
|
||||
setMoreOpen(false)
|
||||
}}>
|
||||
<span className="codicon codicon-versions mr-2" />
|
||||
{t("chat:checkpoint.menu.viewDiffFromInit")}
|
||||
</Button>
|
||||
{/* Hide "View All Changes" for initial checkpoint - already at init */}
|
||||
{!isInitial && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
onDiffFromInit()
|
||||
setMoreOpen(false)
|
||||
}}>
|
||||
<span className="codicon codicon-versions mr-2" />
|
||||
{t("chat:checkpoint.menu.viewDiffFromInit")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -83,7 +83,9 @@ export const CheckpointSaved = ({ checkpoint, currentHash, ...props }: Checkpoin
|
|||
onMouseLeave={handleMouseLeave}>
|
||||
<div className="flex items-center gap-2 text-blue-400 whitespace-nowrap">
|
||||
<GitCommitVertical className="w-4" />
|
||||
<span className="font-semibold">{t("chat:checkpoint.regular")}</span>
|
||||
<span className="font-semibold">
|
||||
{metadata.isInitial ? t("chat:checkpoint.initial") : t("chat:checkpoint.regular")}
|
||||
</span>
|
||||
{isCurrent && <span className="text-muted">({t("chat:checkpoint.current")})</span>}
|
||||
</div>
|
||||
<span
|
||||
|
|
@ -99,6 +101,7 @@ export const CheckpointSaved = ({ checkpoint, currentHash, ...props }: Checkpoin
|
|||
ts={props.ts}
|
||||
commitHash={props.commitHash}
|
||||
checkpoint={metadata}
|
||||
isInitial={metadata.isInitial ?? false}
|
||||
onOpenChange={handlePopoverOpenChange}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
131
webview-ui/src/components/chat/checkpoints/InitialCheckpoint.tsx
Normal file
131
webview-ui/src/components/chat/checkpoints/InitialCheckpoint.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { useMemo, useRef, useState, useEffect, useCallback } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { CheckpointMenu } from "./CheckpointMenu"
|
||||
import { GitCommitVertical, Loader2, AlertTriangle } from "lucide-react"
|
||||
import { StandardTooltip } from "@/components/ui"
|
||||
|
||||
type InitialCheckpointProps = {
|
||||
state: "pending" | "ready" | "failed"
|
||||
hash?: string | null
|
||||
}
|
||||
|
||||
export const InitialCheckpoint = ({ state, hash }: InitialCheckpointProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
|
||||
const [isClosing, setIsClosing] = useState(false)
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
const closeTimer = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimer.current) {
|
||||
window.clearTimeout(closeTimer.current)
|
||||
closeTimer.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handlePopoverOpenChange = useCallback((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 handleMouseEnter = useCallback(() => {
|
||||
setIsHovering(true)
|
||||
}, [])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsHovering(false)
|
||||
}, [])
|
||||
|
||||
// Menu is visible when hovering, popover is open, or briefly after popover closes
|
||||
// But only when the state is 'ready'
|
||||
const menuVisible = state === "ready" && (isHovering || isPopoverOpen || isClosing)
|
||||
|
||||
// Create checkpoint metadata for the menu
|
||||
const checkpointMetadata = useMemo(() => {
|
||||
if (!hash) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
from: hash,
|
||||
to: hash,
|
||||
isInitial: true,
|
||||
}
|
||||
}, [hash])
|
||||
|
||||
const isPending = state === "pending"
|
||||
const isReady = state === "ready"
|
||||
const isFailed = state === "failed"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 pt-2 pb-3",
|
||||
isPending && "opacity-50",
|
||||
isFailed && "opacity-75",
|
||||
)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
data-testid="initial-checkpoint">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 whitespace-nowrap",
|
||||
isReady && "text-blue-400",
|
||||
isPending && "text-muted",
|
||||
isFailed && "text-destructive",
|
||||
)}>
|
||||
{isPending && <Loader2 className="w-4 animate-spin" data-testid="initial-checkpoint-spinner" />}
|
||||
{isReady && <GitCommitVertical className="w-4" />}
|
||||
{isFailed && <AlertTriangle className="w-4" />}
|
||||
<span className="font-semibold">
|
||||
{isPending && t("chat:checkpoint.initializing")}
|
||||
{isReady && t("chat:checkpoint.initial")}
|
||||
{isFailed && (
|
||||
<StandardTooltip content={t("chat:checkpoint.failedDescription")}>
|
||||
<span>{t("chat:checkpoint.failed")}</span>
|
||||
</StandardTooltip>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn("block w-full h-[2px] mt-[2px] text-xs")}
|
||||
style={{
|
||||
backgroundImage: isPending
|
||||
? "linear-gradient(90deg, rgba(128, 128, 128, .4), rgba(128, 128, 128, .4) 80%, rgba(128, 128, 128, 0) 99%)"
|
||||
: isFailed
|
||||
? "linear-gradient(90deg, rgba(239, 68, 68, .4), rgba(239, 68, 68, .4) 80%, rgba(239, 68, 68, 0) 99%)"
|
||||
: "linear-gradient(90deg, rgba(0, 188, 255, .65), rgba(0, 188, 255, .65) 80%, rgba(0, 188, 255, 0) 99%)",
|
||||
}}></span>
|
||||
|
||||
{/* Only show menu when ready and hash is available */}
|
||||
{isReady && hash && checkpointMetadata && (
|
||||
<div
|
||||
data-testid="initial-checkpoint-menu-container"
|
||||
className={cn("h-4 -mt-2", menuVisible ? "block" : "hidden")}>
|
||||
<CheckpointMenu
|
||||
ts={0} // Initial checkpoint doesn't have a ts
|
||||
commitHash={hash}
|
||||
checkpoint={checkpointMetadata}
|
||||
isInitial={true}
|
||||
onOpenChange={handlePopoverOpenChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -186,3 +186,147 @@ describe("CheckpointSaved popover visibility", () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("CheckpointSaved label rendering", () => {
|
||||
const baseProps = {
|
||||
ts: 123,
|
||||
commitHash: "abc123",
|
||||
currentHash: "zzz999",
|
||||
}
|
||||
|
||||
it("renders initial checkpoint label when isInitial is true", () => {
|
||||
const { getByText } = render(
|
||||
<CheckpointSaved
|
||||
{...baseProps}
|
||||
checkpoint={{ from: "abc123", to: "abc123", isInitial: true } as Record<string, unknown>}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Test uses i18n key since translations may not be loaded in test environment
|
||||
expect(getByText("chat:checkpoint.initial")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("renders regular checkpoint label when isInitial is false", () => {
|
||||
const { getByText } = render(
|
||||
<CheckpointSaved
|
||||
{...baseProps}
|
||||
checkpoint={{ from: "prev123", to: "abc123", isInitial: false } as Record<string, unknown>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(getByText("chat:checkpoint.regular")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("renders regular checkpoint label when isInitial is undefined", () => {
|
||||
const { getByText } = render(
|
||||
<CheckpointSaved
|
||||
{...baseProps}
|
||||
checkpoint={{ from: "prev123", to: "abc123" } as Record<string, unknown>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(getByText("chat:checkpoint.regular")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("CheckpointMenu isInitial behavior", () => {
|
||||
const baseProps = {
|
||||
ts: 123,
|
||||
commitHash: "abc123",
|
||||
currentHash: "zzz999",
|
||||
}
|
||||
|
||||
it("hides View Diff button when isInitial is true", () => {
|
||||
const { container } = render(
|
||||
<CheckpointSaved
|
||||
{...baseProps}
|
||||
checkpoint={{ from: "abc123", to: "abc123", isInitial: true } as Record<string, unknown>}
|
||||
/>,
|
||||
)
|
||||
|
||||
// The View Diff button should not be rendered
|
||||
const diffButton = container.querySelector('[aria-label="View Diff"]')
|
||||
expect(diffButton).toBeNull()
|
||||
})
|
||||
|
||||
it("shows View Diff button when isInitial is false", async () => {
|
||||
const { container } = render(
|
||||
<CheckpointSaved
|
||||
{...baseProps}
|
||||
checkpoint={{ from: "prev123", to: "abc123", isInitial: false } as Record<string, unknown>}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Hover to make menu visible
|
||||
const parentDiv = container.querySelector("[class*='flex items-center justify-between']") as HTMLElement
|
||||
fireEvent.mouseEnter(parentDiv)
|
||||
|
||||
// The View Diff button should be rendered (using codicon class as identifier)
|
||||
await waitFor(() => {
|
||||
const diffIcon = container.querySelector(".codicon-diff-single")
|
||||
expect(diffIcon).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it("hides View All Changes button when isInitial is true", async () => {
|
||||
const { container } = render(
|
||||
<CheckpointSaved
|
||||
{...baseProps}
|
||||
checkpoint={{ from: "abc123", to: "abc123", isInitial: true } as Record<string, unknown>}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Hover to make menu visible
|
||||
const parentDiv = container.querySelector("[class*='flex items-center justify-between']") as HTMLElement
|
||||
fireEvent.mouseEnter(parentDiv)
|
||||
|
||||
// Open the "more" popover
|
||||
await waitForOpenHandler()
|
||||
|
||||
// The "View All Changes" button with codicon-versions should not be rendered
|
||||
const versionsIcon = container.querySelector(".codicon-versions")
|
||||
expect(versionsIcon).toBeNull()
|
||||
})
|
||||
|
||||
it("shows View Changes Since This Checkpoint regardless of isInitial", async () => {
|
||||
const { container } = render(
|
||||
<CheckpointSaved
|
||||
{...baseProps}
|
||||
checkpoint={{ from: "abc123", to: "abc123", isInitial: true } as Record<string, unknown>}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Hover to make menu visible
|
||||
const parentDiv = container.querySelector("[class*='flex items-center justify-between']") as HTMLElement
|
||||
fireEvent.mouseEnter(parentDiv)
|
||||
|
||||
// The "View Changes Since This Checkpoint" button with codicon-diff should be present
|
||||
await waitFor(() => {
|
||||
const diffIcon = container.querySelector(".codicon-diff")
|
||||
expect(diffIcon).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it("shows restore options regardless of isInitial", async () => {
|
||||
const { getByTestId, container } = render(
|
||||
<CheckpointSaved
|
||||
{...baseProps}
|
||||
checkpoint={{ from: "abc123", to: "abc123", isInitial: true } as Record<string, unknown>}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Hover to make menu visible
|
||||
const parentDiv = container.querySelector("[class*='flex items-center justify-between']") as HTMLElement
|
||||
fireEvent.mouseEnter(parentDiv)
|
||||
|
||||
// Open the restore popover
|
||||
await waitForOpenHandler()
|
||||
lastOnOpenChange?.(true)
|
||||
|
||||
// Restore buttons should be available
|
||||
await waitFor(() => {
|
||||
expect(getByTestId("restore-files-btn")).toBeTruthy()
|
||||
expect(getByTestId("restore-files-and-task-btn")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
// npx vitest run src/components/chat/checkpoints/__tests__/InitialCheckpoint.spec.tsx
|
||||
|
||||
// Capture onOpenChange from Popover to control open/close in tests
|
||||
let _lastOnOpenChange: ((open: boolean) => void) | undefined
|
||||
|
||||
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: (props: any) => {
|
||||
const { children, onOpenChange, open, ...rest } = props
|
||||
if (rest["data-testid"] === "restore-popover") {
|
||||
_lastOnOpenChange = onOpenChange
|
||||
}
|
||||
return (
|
||||
<div data-testid={rest["data-testid"]} data-open={open}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
PopoverTrigger: ({ children }: any) => <div data-testid="popover-trigger">{children}</div>,
|
||||
PopoverContent: ({ children, className, ...rest }: any) => (
|
||||
<div data-testid="popover-content" className={className} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
import { render, screen, fireEvent, waitFor } from "@/utils/test-utils"
|
||||
import React from "react"
|
||||
import { InitialCheckpoint } from "../InitialCheckpoint"
|
||||
|
||||
describe("InitialCheckpoint", () => {
|
||||
beforeEach(() => {
|
||||
_lastOnOpenChange = undefined
|
||||
})
|
||||
|
||||
describe("visual states", () => {
|
||||
it("renders pending state correctly", () => {
|
||||
const { getByTestId, getByText } = render(<InitialCheckpoint state="pending" />)
|
||||
|
||||
const container = getByTestId("initial-checkpoint")
|
||||
expect(container).toBeTruthy()
|
||||
|
||||
// Should show spinner
|
||||
expect(getByTestId("initial-checkpoint-spinner")).toBeTruthy()
|
||||
|
||||
// Should show initializing text
|
||||
expect(getByText("chat:checkpoint.initializing")).toBeTruthy()
|
||||
|
||||
// Menu should not be visible
|
||||
expect(screen.queryByTestId("initial-checkpoint-menu-container")).toBeNull()
|
||||
})
|
||||
|
||||
it("renders ready state correctly", () => {
|
||||
const { getByTestId, getByText } = render(<InitialCheckpoint state="ready" hash="abc123" />)
|
||||
|
||||
const container = getByTestId("initial-checkpoint")
|
||||
expect(container).toBeTruthy()
|
||||
|
||||
// Should not show spinner
|
||||
expect(screen.queryByTestId("initial-checkpoint-spinner")).toBeNull()
|
||||
|
||||
// Should show "Initial State" text
|
||||
expect(getByText("chat:checkpoint.initial")).toBeTruthy()
|
||||
|
||||
// Menu container should exist
|
||||
expect(getByTestId("initial-checkpoint-menu-container")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("renders failed state correctly", () => {
|
||||
const { getByTestId, getByText } = render(<InitialCheckpoint state="failed" />)
|
||||
|
||||
const container = getByTestId("initial-checkpoint")
|
||||
expect(container).toBeTruthy()
|
||||
|
||||
// Should not show spinner
|
||||
expect(screen.queryByTestId("initial-checkpoint-spinner")).toBeNull()
|
||||
|
||||
// Should show failed text
|
||||
expect(getByText("chat:checkpoint.failed")).toBeTruthy()
|
||||
|
||||
// Menu should not be visible
|
||||
expect(screen.queryByTestId("initial-checkpoint-menu-container")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("menu visibility in ready state", () => {
|
||||
it("hides menu by default when not hovering", () => {
|
||||
const { getByTestId } = render(<InitialCheckpoint state="ready" hash="abc123" />)
|
||||
|
||||
const menuContainer = getByTestId("initial-checkpoint-menu-container")
|
||||
expect(menuContainer.className).toContain("hidden")
|
||||
})
|
||||
|
||||
it("shows menu when hovering", async () => {
|
||||
const { getByTestId } = render(<InitialCheckpoint state="ready" hash="abc123" />)
|
||||
|
||||
const container = getByTestId("initial-checkpoint")
|
||||
const menuContainer = getByTestId("initial-checkpoint-menu-container")
|
||||
|
||||
// Initially hidden
|
||||
expect(menuContainer.className).toContain("hidden")
|
||||
|
||||
// Hover to show menu
|
||||
fireEvent.mouseEnter(container)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(menuContainer.className).toContain("block")
|
||||
expect(menuContainer.className).not.toContain("hidden")
|
||||
})
|
||||
|
||||
// Mouse leave to hide menu
|
||||
fireEvent.mouseLeave(container)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(menuContainer.className).toContain("hidden")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("styling", () => {
|
||||
it("applies opacity styling for pending state", () => {
|
||||
const { getByTestId } = render(<InitialCheckpoint state="pending" />)
|
||||
|
||||
const container = getByTestId("initial-checkpoint")
|
||||
expect(container.className).toContain("opacity-50")
|
||||
})
|
||||
|
||||
it("applies opacity styling for failed state", () => {
|
||||
const { getByTestId } = render(<InitialCheckpoint state="failed" />)
|
||||
|
||||
const container = getByTestId("initial-checkpoint")
|
||||
expect(container.className).toContain("opacity-75")
|
||||
})
|
||||
|
||||
it("does not apply opacity for ready state", () => {
|
||||
const { getByTestId } = render(<InitialCheckpoint state="ready" hash="abc123" />)
|
||||
|
||||
const container = getByTestId("initial-checkpoint")
|
||||
expect(container.className).not.toContain("opacity-50")
|
||||
expect(container.className).not.toContain("opacity-75")
|
||||
})
|
||||
})
|
||||
|
||||
describe("menu not rendered without hash in ready state", () => {
|
||||
it("does not render menu when hash is null in ready state", () => {
|
||||
render(<InitialCheckpoint state="ready" hash={null} />)
|
||||
|
||||
expect(screen.queryByTestId("initial-checkpoint-menu-container")).toBeNull()
|
||||
})
|
||||
|
||||
it("does not render menu when hash is undefined in ready state", () => {
|
||||
render(<InitialCheckpoint state="ready" />)
|
||||
|
||||
expect(screen.queryByTestId("initial-checkpoint-menu-container")).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -3,6 +3,7 @@ import { z } from "zod"
|
|||
export const checkpointSchema = z.object({
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
isInitial: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type Checkpoint = z.infer<typeof checkpointSchema>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export interface ExtensionStateContextType extends ExtensionState {
|
|||
mcpServers: McpServer[]
|
||||
hasSystemPromptOverride?: boolean
|
||||
currentCheckpoint?: string
|
||||
initialCheckpointState?: "pending" | "ready" | "failed" | null
|
||||
initialCheckpointHash?: string | null
|
||||
currentTaskTodos?: TodoItem[] // Initial todos for the current task
|
||||
filePaths: string[]
|
||||
openedTabs: Array<{ label: string; isActive: boolean; path?: string }>
|
||||
|
|
@ -283,6 +285,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
const [skills, setSkills] = useState<SkillMetadata[]>([])
|
||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
|
||||
const [currentCheckpoint, setCurrentCheckpoint] = useState<string>()
|
||||
const [initialCheckpointState, setInitialCheckpointState] = useState<"pending" | "ready" | "failed" | null>(null)
|
||||
const [initialCheckpointHash, setInitialCheckpointHash] = useState<string | null>(null)
|
||||
const [extensionRouterModels, setExtensionRouterModels] = useState<RouterModels | undefined>(undefined)
|
||||
const [marketplaceItems, setMarketplaceItems] = useState<any[]>([])
|
||||
const [alwaysAllowFollowupQuestions, setAlwaysAllowFollowupQuestions] = useState(false) // Add state for follow-up questions auto-approve
|
||||
|
|
@ -405,6 +409,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
setCurrentCheckpoint(message.text)
|
||||
break
|
||||
}
|
||||
case "initialCheckpointState": {
|
||||
setInitialCheckpointState(message.initialCheckpointState ?? null)
|
||||
setInitialCheckpointHash(message.initialCheckpointHash ?? null)
|
||||
break
|
||||
}
|
||||
case "listApiConfig": {
|
||||
setListApiConfigMeta(message.listApiConfig ?? [])
|
||||
break
|
||||
|
|
@ -492,6 +501,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
theme,
|
||||
mcpServers,
|
||||
currentCheckpoint,
|
||||
initialCheckpointState,
|
||||
initialCheckpointHash,
|
||||
filePaths,
|
||||
openedTabs,
|
||||
commands,
|
||||
|
|
|
|||
|
|
@ -171,6 +171,10 @@
|
|||
},
|
||||
"checkpoint": {
|
||||
"regular": "Checkpoint",
|
||||
"initial": "Initial State",
|
||||
"initializing": "Initializing checkpoint...",
|
||||
"failed": "Checkpoint initialization failed",
|
||||
"failedDescription": "Unable to create initial checkpoint. Restore features may be unavailable for this task.",
|
||||
"initializingWarning": "Still initializing checkpoint... If this takes too long, you can disable checkpoints in <settingsLink>settings</settingsLink> and restart your task.",
|
||||
"menu": {
|
||||
"viewDiff": "View Diff",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue