Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export interface ExtensionMessage {
| "deleteCustomModeCheck"
| "currentCheckpointUpdated"
| "checkpointInitWarning"
| "initialCheckpointState"
| "browserToolEnabled"
| "browserConnectionResult"
| "remoteBrowserEnabled"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 26 additions & 1 deletion src/core/checkpoints/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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 }) => {
Expand Down Expand Up @@ -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
}
Expand Down
11 changes: 11 additions & 0 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -97,6 +98,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
messageQueue = [],
isBrowserSessionActive,
showWorktreesInHomeScreen,
initialCheckpointState,
initialCheckpointHash,
enableCheckpoints,
} = useExtensionState()

const messagesRef = useRef(messages)
Expand Down Expand Up @@ -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">
Expand Down
37 changes: 22 additions & 15 deletions webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type CheckpointMenuBaseProps = {
ts: number
commitHash: string
checkpoint: Checkpoint
isInitial?: boolean
}
type CheckpointMenuControlledProps = {
onOpenChange: (open: boolean) => void
Expand All @@ -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)
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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={() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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>
Expand Down
131 changes: 131 additions & 0 deletions webview-ui/src/components/chat/checkpoints/InitialCheckpoint.tsx
Original file line number Diff line number Diff line change
@@ -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={{

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

InitialCheckpoint uses an inline style to set the gradient background. This makes the element harder to theme (VS Code theme vars) and goes against the project preference for Tailwind/CSS vars for new markup; consider moving these gradients into CSS (eg a small class per state) and toggling via className instead.

Fix it with Roo Code or mention @roomote and request a fix.

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>
Comment on lines +104 to +113

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

InitialCheckpoint uses an inline style for the gradient. This project prefers Tailwind + VSCode CSS vars for new markup; inline styles here will be harder to theme and keep consistent. Consider extracting the gradient into a class (or using existing utility patterns) so theme changes can be handled in CSS.

Fix it with Roo Code or mention @roomote and request a fix.


{/* 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In InitialCheckpoint, CheckpointMenu is passed ts={0}, but the backend restore handler looks up the target message by timestamp and returns early if it cannot find a matching message. Since no chat message will ever have ts=0, initial checkpoint preview/restore will be a no-op. Consider making restore preview independent of ts, special-casing initial restore to rewind to task start, or passing a real ts for the restore point.

Fix it with Roo Code or mention @roomote and request a fix.

commitHash={hash}
checkpoint={checkpointMetadata}
isInitial={true}
onOpenChange={handlePopoverOpenChange}
/>
</div>
)}
</div>
)
}
Loading
Loading