Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 8a433c6

Browse files
committed
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
1 parent 0c53f19 commit 8a433c6

11 files changed

Lines changed: 523 additions & 17 deletions

File tree

packages/types/src/vscode-extension-host.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export interface ExtensionMessage {
6060
| "deleteCustomModeCheck"
6161
| "currentCheckpointUpdated"
6262
| "checkpointInitWarning"
63+
| "initialCheckpointState"
6364
| "browserToolEnabled"
6465
| "browserConnectionResult"
6566
| "remoteBrowserEnabled"
@@ -116,6 +117,9 @@ export interface ExtensionMessage {
116117
type: "WAIT_TIMEOUT" | "INIT_TIMEOUT"
117118
timeout: number
118119
}
120+
// Initial checkpoint state for state-driven UI
121+
initialCheckpointState?: "pending" | "ready" | "failed" | null
122+
initialCheckpointHash?: string | null
119123
action?:
120124
| "chatButtonClicked"
121125
| "settingsButtonClicked"
@@ -408,6 +412,10 @@ export type ExtensionState = Pick<
408412
featureRoomoteControlEnabled: boolean
409413
openAiCodexIsAuthenticated?: boolean
410414
debug?: boolean
415+
416+
// Initial checkpoint state for state-driven UI
417+
initialCheckpointState?: "pending" | "ready" | "failed" | null
418+
initialCheckpointHash?: string | null
411419
}
412420

413421
export interface Command {

src/core/checkpoints/index.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ function sendCheckpointInitWarn(task: Task, type?: "WAIT_TIMEOUT" | "INIT_TIMEOU
2525
})
2626
}
2727

28+
function sendInitialCheckpointState(task: Task, state: "pending" | "ready" | "failed" | null, hash?: string) {
29+
task.providerRef.deref()?.postMessageToWebview({
30+
type: "initialCheckpointState",
31+
initialCheckpointState: state,
32+
initialCheckpointHash: hash ?? null,
33+
})
34+
}
35+
2836
export async function getCheckpointService(task: Task, { interval = 250 }: { interval?: number } = {}) {
2937
if (!task.enableCheckpoints) {
3038
return undefined
@@ -98,6 +106,7 @@ export async function getCheckpointService(task: Task, { interval = 250 }: { int
98106
)
99107
if (!task?.checkpointService) {
100108
sendCheckpointInitWarn(task, "INIT_TIMEOUT", task.checkpointTimeout)
109+
sendInitialCheckpointState(task, "failed")
101110
task.enableCheckpoints = false
102111
return undefined
103112
} else {
@@ -121,6 +130,7 @@ export async function getCheckpointService(task: Task, { interval = 250 }: { int
121130
} catch (err) {
122131
if (err.name === "TimeoutError" && task.enableCheckpoints) {
123132
sendCheckpointInitWarn(task, "INIT_TIMEOUT", task.checkpointTimeout)
133+
sendInitialCheckpointState(task, "failed")
124134
}
125135
log(`[Task#getCheckpointService] ${err.message}`)
126136
task.enableCheckpoints = false
@@ -140,6 +150,7 @@ async function checkGitInstallation(
140150

141151
if (!gitInstalled) {
142152
log("[Task#getCheckpointService] Git is not installed, disabling checkpoints")
153+
sendInitialCheckpointState(task, "failed")
143154
task.enableCheckpoints = false
144155
task.checkpointServiceInitializing = false
145156

@@ -157,9 +168,18 @@ async function checkGitInstallation(
157168
}
158169

159170
// Git is installed, proceed with initialization
160-
service.on("initialize", () => {
171+
service.on("initialize", ({ baseHash }) => {
161172
log("[Task#getCheckpointService] service initialized")
162173
task.checkpointServiceInitializing = false
174+
175+
// Send initial checkpoint state as ready with the hash
176+
sendInitialCheckpointState(task, "ready", baseHash)
177+
178+
// Update webview with initial checkpoint hash (for currentCheckpoint tracking)
179+
provider?.postMessageToWebview({
180+
type: "currentCheckpointUpdated",
181+
text: baseHash,
182+
})
163183
})
164184

165185
service.on("checkpoint", ({ fromHash: from, toHash: to, suppressMessage }) => {
@@ -195,15 +215,20 @@ async function checkGitInstallation(
195215

196216
log("[Task#getCheckpointService] initializing shadow git")
197217

218+
// Set pending state before starting initialization
219+
sendInitialCheckpointState(task, "pending")
220+
198221
try {
199222
await service.initShadowGit()
200223
} catch (err) {
201224
log(`[Task#getCheckpointService] initShadowGit -> ${err.message}`)
225+
sendInitialCheckpointState(task, "failed")
202226
task.enableCheckpoints = false
203227
}
204228
} catch (err) {
205229
log(`[Task#getCheckpointService] Unexpected error during Git check: ${err.message}`)
206230
console.error("Git check error:", err)
231+
sendInitialCheckpointState(task, "failed")
207232
task.enableCheckpoints = false
208233
task.checkpointServiceInitializing = false
209234
}

webview-ui/src/components/chat/ChatView.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import TaskHeader from "./TaskHeader"
4444
import SystemPromptWarning from "./SystemPromptWarning"
4545
import ProfileViolationWarning from "./ProfileViolationWarning"
4646
import { CheckpointWarning } from "./CheckpointWarning"
47+
import { InitialCheckpoint } from "./checkpoints/InitialCheckpoint"
4748
import { QueuedMessages } from "./QueuedMessages"
4849
import { WorktreeSelector } from "./WorktreeSelector"
4950
import DismissibleUpsell from "../common/DismissibleUpsell"
@@ -97,6 +98,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
9798
messageQueue = [],
9899
isBrowserSessionActive,
99100
showWorktreesInHomeScreen,
101+
initialCheckpointState,
102+
initialCheckpointHash,
103+
enableCheckpoints,
100104
} = useExtensionState()
101105

102106
const messagesRef = useRef(messages)
@@ -1510,6 +1514,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
15101514
<CheckpointWarning warning={checkpointWarning} />
15111515
</div>
15121516
)}
1517+
1518+
{/* Initial Checkpoint UI - state-driven element */}
1519+
{initialCheckpointState && enableCheckpoints && (
1520+
<div className="px-3">
1521+
<InitialCheckpoint state={initialCheckpointState} hash={initialCheckpointHash} />
1522+
</div>
1523+
)}
15131524
</>
15141525
) : (
15151526
<div className="flex flex-col h-full justify-center p-6 min-h-0 overflow-y-auto gap-4 relative">

webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type CheckpointMenuBaseProps = {
1212
ts: number
1313
commitHash: string
1414
checkpoint: Checkpoint
15+
isInitial?: boolean
1516
}
1617
type CheckpointMenuControlledProps = {
1718
onOpenChange: (open: boolean) => void
@@ -21,7 +22,7 @@ type CheckpointMenuUncontrolledProps = {
2122
}
2223
type CheckpointMenuProps = CheckpointMenuBaseProps & (CheckpointMenuControlledProps | CheckpointMenuUncontrolledProps)
2324

24-
export const CheckpointMenu = ({ ts, commitHash, checkpoint, onOpenChange }: CheckpointMenuProps) => {
25+
export const CheckpointMenu = ({ ts, commitHash, checkpoint, isInitial, onOpenChange }: CheckpointMenuProps) => {
2526
const { t } = useTranslation()
2627
const [internalRestoreOpen, setInternalRestoreOpen] = useState(false)
2728
const [restoreConfirming, setRestoreConfirming] = useState(false)
@@ -95,11 +96,14 @@ export const CheckpointMenu = ({ ts, commitHash, checkpoint, onOpenChange }: Che
9596

9697
return (
9798
<div className="flex flex-row gap-1">
98-
<StandardTooltip content={t("chat:checkpoint.menu.viewDiff")}>
99-
<Button variant="ghost" size="icon" onClick={onCheckpointDiff}>
100-
<span className="codicon codicon-diff-single" />
101-
</Button>
102-
</StandardTooltip>
99+
{/* Hide "View Diff" for initial checkpoint - no previous checkpoint to diff against */}
100+
{!isInitial && (
101+
<StandardTooltip content={t("chat:checkpoint.menu.viewDiff")}>
102+
<Button variant="ghost" size="icon" onClick={onCheckpointDiff}>
103+
<span className="codicon codicon-diff-single" />
104+
</Button>
105+
</StandardTooltip>
106+
)}
103107
<Popover
104108
open={restoreOpen}
105109
onOpenChange={(open) => {
@@ -175,15 +179,18 @@ export const CheckpointMenu = ({ ts, commitHash, checkpoint, onOpenChange }: Che
175179
</StandardTooltip>
176180
<PopoverContent align="end" container={portalContainer} className="w-auto min-w-max">
177181
<div className="flex flex-col gap-2">
178-
<Button
179-
variant="secondary"
180-
onClick={() => {
181-
onDiffFromInit()
182-
setMoreOpen(false)
183-
}}>
184-
<span className="codicon codicon-versions mr-2" />
185-
{t("chat:checkpoint.menu.viewDiffFromInit")}
186-
</Button>
182+
{/* Hide "View All Changes" for initial checkpoint - already at init */}
183+
{!isInitial && (
184+
<Button
185+
variant="secondary"
186+
onClick={() => {
187+
onDiffFromInit()
188+
setMoreOpen(false)
189+
}}>
190+
<span className="codicon codicon-versions mr-2" />
191+
{t("chat:checkpoint.menu.viewDiffFromInit")}
192+
</Button>
193+
)}
187194
<Button
188195
variant="secondary"
189196
onClick={() => {

webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,9 @@ export const CheckpointSaved = ({ checkpoint, currentHash, ...props }: Checkpoin
8383
onMouseLeave={handleMouseLeave}>
8484
<div className="flex items-center gap-2 text-blue-400 whitespace-nowrap">
8585
<GitCommitVertical className="w-4" />
86-
<span className="font-semibold">{t("chat:checkpoint.regular")}</span>
86+
<span className="font-semibold">
87+
{metadata.isInitial ? t("chat:checkpoint.initial") : t("chat:checkpoint.regular")}
88+
</span>
8789
{isCurrent && <span className="text-muted">({t("chat:checkpoint.current")})</span>}
8890
</div>
8991
<span
@@ -99,6 +101,7 @@ export const CheckpointSaved = ({ checkpoint, currentHash, ...props }: Checkpoin
99101
ts={props.ts}
100102
commitHash={props.commitHash}
101103
checkpoint={metadata}
104+
isInitial={metadata.isInitial ?? false}
102105
onOpenChange={handlePopoverOpenChange}
103106
/>
104107
</div>
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { useMemo, useRef, useState, useEffect, useCallback } from "react"
2+
import { useTranslation } from "react-i18next"
3+
import { cn } from "@/lib/utils"
4+
5+
import { CheckpointMenu } from "./CheckpointMenu"
6+
import { GitCommitVertical, Loader2, AlertTriangle } from "lucide-react"
7+
import { StandardTooltip } from "@/components/ui"
8+
9+
type InitialCheckpointProps = {
10+
state: "pending" | "ready" | "failed"
11+
hash?: string | null
12+
}
13+
14+
export const InitialCheckpoint = ({ state, hash }: InitialCheckpointProps) => {
15+
const { t } = useTranslation()
16+
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
17+
const [isClosing, setIsClosing] = useState(false)
18+
const [isHovering, setIsHovering] = useState(false)
19+
const closeTimer = useRef<number | null>(null)
20+
21+
useEffect(() => {
22+
return () => {
23+
if (closeTimer.current) {
24+
window.clearTimeout(closeTimer.current)
25+
closeTimer.current = null
26+
}
27+
}
28+
}, [])
29+
30+
const handlePopoverOpenChange = useCallback((open: boolean) => {
31+
setIsPopoverOpen(open)
32+
if (open) {
33+
setIsClosing(false)
34+
if (closeTimer.current) {
35+
window.clearTimeout(closeTimer.current)
36+
closeTimer.current = null
37+
}
38+
} else {
39+
setIsClosing(true)
40+
closeTimer.current = window.setTimeout(() => {
41+
setIsClosing(false)
42+
closeTimer.current = null
43+
}, 200) // keep menu visible briefly to avoid popover jump
44+
}
45+
}, [])
46+
47+
const handleMouseEnter = useCallback(() => {
48+
setIsHovering(true)
49+
}, [])
50+
51+
const handleMouseLeave = useCallback(() => {
52+
setIsHovering(false)
53+
}, [])
54+
55+
// Menu is visible when hovering, popover is open, or briefly after popover closes
56+
// But only when the state is 'ready'
57+
const menuVisible = state === "ready" && (isHovering || isPopoverOpen || isClosing)
58+
59+
// Create checkpoint metadata for the menu
60+
const checkpointMetadata = useMemo(() => {
61+
if (!hash) {
62+
return undefined
63+
}
64+
return {
65+
from: hash,
66+
to: hash,
67+
isInitial: true,
68+
}
69+
}, [hash])
70+
71+
const isPending = state === "pending"
72+
const isReady = state === "ready"
73+
const isFailed = state === "failed"
74+
75+
return (
76+
<div
77+
className={cn(
78+
"flex items-center justify-between gap-2 pt-2 pb-3",
79+
isPending && "opacity-50",
80+
isFailed && "opacity-75",
81+
)}
82+
onMouseEnter={handleMouseEnter}
83+
onMouseLeave={handleMouseLeave}
84+
data-testid="initial-checkpoint">
85+
<div
86+
className={cn(
87+
"flex items-center gap-2 whitespace-nowrap",
88+
isReady && "text-blue-400",
89+
isPending && "text-muted",
90+
isFailed && "text-destructive",
91+
)}>
92+
{isPending && <Loader2 className="w-4 animate-spin" data-testid="initial-checkpoint-spinner" />}
93+
{isReady && <GitCommitVertical className="w-4" />}
94+
{isFailed && <AlertTriangle className="w-4" />}
95+
<span className="font-semibold">
96+
{isPending && t("chat:checkpoint.initializing")}
97+
{isReady && t("chat:checkpoint.initial")}
98+
{isFailed && (
99+
<StandardTooltip content={t("chat:checkpoint.failedDescription")}>
100+
<span>{t("chat:checkpoint.failed")}</span>
101+
</StandardTooltip>
102+
)}
103+
</span>
104+
</div>
105+
<span
106+
className={cn("block w-full h-[2px] mt-[2px] text-xs")}
107+
style={{
108+
backgroundImage: isPending
109+
? "linear-gradient(90deg, rgba(128, 128, 128, .4), rgba(128, 128, 128, .4) 80%, rgba(128, 128, 128, 0) 99%)"
110+
: isFailed
111+
? "linear-gradient(90deg, rgba(239, 68, 68, .4), rgba(239, 68, 68, .4) 80%, rgba(239, 68, 68, 0) 99%)"
112+
: "linear-gradient(90deg, rgba(0, 188, 255, .65), rgba(0, 188, 255, .65) 80%, rgba(0, 188, 255, 0) 99%)",
113+
}}></span>
114+
115+
{/* Only show menu when ready and hash is available */}
116+
{isReady && hash && checkpointMetadata && (
117+
<div
118+
data-testid="initial-checkpoint-menu-container"
119+
className={cn("h-4 -mt-2", menuVisible ? "block" : "hidden")}>
120+
<CheckpointMenu
121+
ts={0} // Initial checkpoint doesn't have a ts
122+
commitHash={hash}
123+
checkpoint={checkpointMetadata}
124+
isInitial={true}
125+
onOpenChange={handlePopoverOpenChange}
126+
/>
127+
</div>
128+
)}
129+
</div>
130+
)
131+
}

0 commit comments

Comments
 (0)