Skip to content

Commit 2bb8260

Browse files
authored
feat(chat): add previous checkpoint navigation controls and i18n (#12139)
1 parent 3e202eb commit 2bb8260

25 files changed

Lines changed: 251 additions & 15 deletions

File tree

src/esbuild.mjs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,24 @@ import { copyPaths, copyWasms, copyLocales, setupLocaleWatcher } from "@roo-code
1010
const __filename = fileURLToPath(import.meta.url)
1111
const __dirname = path.dirname(__filename)
1212

13+
async function removeDirWithRetries(dirPath, retries = 5, retryDelayMs = 200) {
14+
for (let attempt = 0; attempt <= retries; attempt++) {
15+
try {
16+
await fs.promises.rm(dirPath, { recursive: true, force: true })
17+
return
18+
} catch (error) {
19+
const isRetryable = error?.code === "ENOTEMPTY" || error?.code === "EBUSY" || error?.code === "EPERM"
20+
const isLastAttempt = attempt === retries
21+
22+
if (!isRetryable || isLastAttempt) {
23+
throw error
24+
}
25+
26+
await new Promise((resolve) => globalThis.setTimeout(resolve, retryDelayMs * (attempt + 1)))
27+
}
28+
}
29+
}
30+
1331
async function main() {
1432
const name = "extension"
1533
const production = process.argv.includes("--production")
@@ -36,7 +54,7 @@ async function main() {
3654

3755
if (fs.existsSync(distDir)) {
3856
console.log(`[${name}] Cleaning dist directory: ${distDir}`)
39-
fs.rmSync(distDir, { recursive: true, force: true })
57+
await removeDirWithRetries(distDir)
4058
}
4159

4260
/**

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ interface ChatRowProps {
124124
isFollowUpAutoApprovalPaused?: boolean
125125
editable?: boolean
126126
hasCheckpoint?: boolean
127+
onJumpToPreviousCheckpoint?: () => void
127128
}
128129

129130
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
@@ -177,6 +178,7 @@ export const ChatRowContent = ({
177178
onBatchFileResponse,
178179
isFollowUpAnswered,
179180
isFollowUpAutoApprovalPaused,
181+
onJumpToPreviousCheckpoint,
180182
}: ChatRowContentProps) => {
181183
const { t, i18n } = useTranslation()
182184

@@ -1341,6 +1343,7 @@ export const ChatRowContent = ({
13411343
commitHash={message.text!}
13421344
currentHash={currentCheckpoint}
13431345
checkpoint={message.checkpoint}
1346+
onJumpToPreviousCheckpoint={onJumpToPreviousCheckpoint}
13441347
/>
13451348
)
13461349
case "condense_context":

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

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,6 +1261,23 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
12611261
return result
12621262
}, [isCondensing, visibleMessages])
12631263

1264+
const checkpointIndices = useMemo(() => {
1265+
const indices: number[] = []
1266+
for (let i = 0; i < groupedMessages.length; i++) {
1267+
if (groupedMessages[i]?.say === "checkpoint_saved") {
1268+
indices.push(i)
1269+
}
1270+
}
1271+
return indices
1272+
}, [groupedMessages])
1273+
1274+
const hasLatestCheckpoint = checkpointIndices.length > 0
1275+
const checkpointJumpCursorRef = useRef<number | null>(null)
1276+
1277+
useEffect(() => {
1278+
checkpointJumpCursorRef.current = null
1279+
}, [task?.ts, checkpointIndices])
1280+
12641281
// Scroll lifecycle is managed by a dedicated hook to keep ChatView focused
12651282
// on message handling and UI orchestration.
12661283
const {
@@ -1394,6 +1411,29 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
13941411
vscode.postMessage({ type: "cancelAutoApproval" })
13951412
}, [])
13961413

1414+
const handleScrollToBottomAndResetCheckpointCursor = useCallback(() => {
1415+
checkpointJumpCursorRef.current = null
1416+
handleScrollToBottomClick()
1417+
}, [handleScrollToBottomClick])
1418+
1419+
const handleScrollToLatestCheckpoint = useCallback(() => {
1420+
if (checkpointIndices.length === 0) {
1421+
return
1422+
}
1423+
1424+
const previousCursor = checkpointJumpCursorRef.current
1425+
const nextCursor = previousCursor === null ? checkpointIndices.length - 1 : Math.max(0, previousCursor - 1)
1426+
const nextCheckpointIndex = checkpointIndices[nextCursor]
1427+
checkpointJumpCursorRef.current = nextCursor
1428+
1429+
enterUserBrowsingHistory("keyboard-nav-up")
1430+
virtuosoRef.current?.scrollToIndex({
1431+
index: nextCheckpointIndex,
1432+
align: "center",
1433+
behavior: "smooth",
1434+
})
1435+
}, [checkpointIndices, enterUserBrowsingHistory])
1436+
13971437
const itemContent = useCallback(
13981438
(index: number, messageOrGroup: ClineMessage) => {
13991439
const hasCheckpoint = modifiedMessages.some((message) => message.say === "checkpoint_saved")
@@ -1430,6 +1470,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
14301470
})()
14311471
}
14321472
hasCheckpoint={hasCheckpoint}
1473+
onJumpToPreviousCheckpoint={handleScrollToLatestCheckpoint}
14331474
/>
14341475
)
14351476
},
@@ -1447,6 +1488,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
14471488
isFollowUpAutoApprovalPaused,
14481489
enableButtons,
14491490
primaryButtonText,
1491+
handleScrollToLatestCheckpoint,
14501492
],
14511493
)
14521494

@@ -1641,14 +1683,27 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
16411683
showScrollToBottom ? "opacity-100" : enableButtons ? "opacity-100" : "opacity-50"
16421684
}`}>
16431685
{showScrollToBottom ? (
1644-
<StandardTooltip content={t("chat:scrollToBottom")}>
1645-
<Button
1646-
variant="secondary"
1647-
className="flex-[2]"
1648-
onClick={handleScrollToBottomClick}>
1649-
<span className="codicon codicon-chevron-down"></span>
1650-
</Button>
1651-
</StandardTooltip>
1686+
<>
1687+
<StandardTooltip content={t("chat:scrollToBottom")}>
1688+
<Button
1689+
variant="secondary"
1690+
className={hasLatestCheckpoint ? "flex-1 mr-[6px]" : "flex-[2]"}
1691+
onClick={handleScrollToBottomAndResetCheckpointCursor}>
1692+
<span className="codicon codicon-chevron-down"></span>
1693+
</Button>
1694+
</StandardTooltip>
1695+
{hasLatestCheckpoint && (
1696+
<StandardTooltip content={t("chat:scrollToLatestCheckpoint")}>
1697+
<Button
1698+
variant="secondary"
1699+
className="flex-1 ml-[6px]"
1700+
onClick={handleScrollToLatestCheckpoint}
1701+
aria-label={t("chat:scrollToLatestCheckpoint")}>
1702+
<span className="codicon codicon-history"></span>
1703+
</Button>
1704+
</StandardTooltip>
1705+
)}
1706+
</>
16521707
) : (
16531708
<>
16541709
{primaryButtonText && (

webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ interface MockVirtuosoProps {
4343

4444
interface VirtuosoHarnessState {
4545
scrollCalls: number
46+
scrollToIndexArgs: Array<{
47+
index: number | "LAST"
48+
align?: "end" | "start" | "center"
49+
behavior?: "auto" | "smooth"
50+
}>
4651
atBottomAfterCalls: number
4752
signalDelayMs: number
4853
emitFalseOnDataChange: boolean
@@ -54,6 +59,7 @@ interface VirtuosoHarnessState {
5459

5560
const harness = vi.hoisted<VirtuosoHarnessState>(() => ({
5661
scrollCalls: 0,
62+
scrollToIndexArgs: [],
5763
atBottomAfterCalls: Number.POSITIVE_INFINITY,
5864
signalDelayMs: 20,
5965
emitFalseOnDataChange: true,
@@ -152,8 +158,9 @@ vi.mock("react-virtuoso", () => {
152158
}
153159

154160
useImperativeHandle(ref, () => ({
155-
scrollToIndex: () => {
161+
scrollToIndex: (options) => {
156162
harness.scrollCalls += 1
163+
harness.scrollToIndexArgs.push(options)
157164
const reachedBottom = harness.scrollCalls >= harness.atBottomAfterCalls
158165
const timeoutId = window.setTimeout(() => {
159166
atBottomRef.current?.(reachedBottom)
@@ -215,6 +222,23 @@ const buildMessages = (baseTs: number): ClineMessage[] => [
215222
{ type: "say", say: "text", ts: baseTs + 2, text: "row-2" },
216223
]
217224

225+
const buildMessagesWithCheckpoint = (baseTs: number): ClineMessage[] => [
226+
{ type: "say", say: "text", ts: baseTs, text: "task" },
227+
{ type: "say", say: "text", ts: baseTs + 1, text: "row-1" },
228+
{ type: "say", say: "checkpoint_saved", ts: baseTs + 2, text: "checkpoint-1" },
229+
{ type: "say", say: "text", ts: baseTs + 3, text: "row-2" },
230+
]
231+
232+
const buildMessagesWithMultipleCheckpoints = (baseTs: number): ClineMessage[] => [
233+
{ type: "say", say: "text", ts: baseTs, text: "task" },
234+
{ type: "say", say: "checkpoint_saved", ts: baseTs + 1, text: "checkpoint-1" },
235+
{ type: "say", say: "text", ts: baseTs + 2, text: "row-2" },
236+
{ type: "say", say: "checkpoint_saved", ts: baseTs + 3, text: "checkpoint-2" },
237+
{ type: "say", say: "text", ts: baseTs + 4, text: "row-4" },
238+
{ type: "say", say: "checkpoint_saved", ts: baseTs + 5, text: "checkpoint-3" },
239+
{ type: "say", say: "text", ts: baseTs + 6, text: "row-6" },
240+
]
241+
218242
const resolveFollowOutput = (isAtBottom: boolean): "auto" | false => {
219243
const followOutput = harness.followOutput
220244
if (typeof followOutput === "function") {
@@ -254,19 +278,19 @@ const renderView = () =>
254278
</ExtensionStateContextProvider>,
255279
)
256280

257-
const hydrate = async (atBottomAfterCalls: number) => {
281+
const hydrate = async (atBottomAfterCalls: number, clineMessages = buildMessages(Date.now() - 3_000)) => {
258282
harness.atBottomAfterCalls = atBottomAfterCalls
259283
renderView()
260284
await act(async () => {
261285
await Promise.resolve()
262286
})
263287
await act(async () => {
264-
postState(buildMessages(Date.now() - 3_000))
288+
postState(clineMessages)
265289
})
266290
await waitFor(() => {
267291
const list = document.querySelector("[data-testid='virtuoso-item-list']")
268292
expect(list).toBeTruthy()
269-
expect(list?.getAttribute("data-count")).toBe("2")
293+
expect(list?.getAttribute("data-count")).toBe(String(Math.max(0, clineMessages.length - 1)))
270294
})
271295
}
272296

@@ -317,9 +341,19 @@ const getScrollToBottomButton = (): HTMLButtonElement => {
317341
return button
318342
}
319343

344+
const getScrollToCheckpointButton = (): HTMLButtonElement => {
345+
const button = document.querySelector("button[aria-label='chat:scrollToLatestCheckpoint']")
346+
if (!(button instanceof HTMLButtonElement)) {
347+
throw new Error("Expected scroll-to-checkpoint button")
348+
}
349+
350+
return button
351+
}
352+
320353
describe("ChatView scroll behavior regression coverage", () => {
321354
beforeEach(() => {
322355
harness.scrollCalls = 0
356+
harness.scrollToIndexArgs = []
323357
harness.atBottomAfterCalls = Number.POSITIVE_INFINITY
324358
harness.signalDelayMs = 20
325359
harness.emitFalseOnDataChange = true
@@ -503,4 +537,71 @@ describe("ChatView scroll behavior regression coverage", () => {
503537
})
504538
await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeNull(), { timeout: 1_200 })
505539
})
540+
541+
it("shows jump-to-checkpoint button and scrolls to latest checkpoint", async () => {
542+
await hydrate(2, buildMessagesWithCheckpoint(Date.now() - 3_000))
543+
await waitForCalls(2)
544+
await waitForCallsSettled()
545+
546+
await act(async () => {
547+
fireEvent.keyDown(window, { key: "PageUp" })
548+
})
549+
550+
await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), {
551+
timeout: 1_200,
552+
})
553+
554+
const checkpointButton = document.querySelector("button[aria-label='chat:scrollToLatestCheckpoint']")
555+
expect(checkpointButton).toBeInstanceOf(HTMLButtonElement)
556+
557+
const callsBeforeClick = harness.scrollCalls
558+
559+
await act(async () => {
560+
;(checkpointButton as HTMLButtonElement).click()
561+
})
562+
563+
expect(harness.scrollCalls).toBe(callsBeforeClick + 1)
564+
expect(harness.scrollToIndexArgs.at(-1)).toMatchObject({
565+
index: 1,
566+
align: "center",
567+
behavior: "smooth",
568+
})
569+
})
570+
571+
it("repeated checkpoint clicks step backward through previous checkpoints", async () => {
572+
await hydrate(2, buildMessagesWithMultipleCheckpoints(Date.now() - 3_000))
573+
await waitForCalls(2)
574+
await waitForCallsSettled()
575+
576+
await act(async () => {
577+
fireEvent.keyDown(window, { key: "PageUp" })
578+
})
579+
580+
await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), {
581+
timeout: 1_200,
582+
})
583+
584+
const checkpointButton = getScrollToCheckpointButton()
585+
586+
await act(async () => {
587+
;(checkpointButton as HTMLButtonElement).click()
588+
})
589+
expect(harness.scrollToIndexArgs.at(-1)).toMatchObject({ index: 4, align: "center", behavior: "smooth" })
590+
591+
await act(async () => {
592+
;(checkpointButton as HTMLButtonElement).click()
593+
})
594+
expect(harness.scrollToIndexArgs.at(-1)).toMatchObject({ index: 2, align: "center", behavior: "smooth" })
595+
596+
await act(async () => {
597+
;(checkpointButton as HTMLButtonElement).click()
598+
})
599+
expect(harness.scrollToIndexArgs.at(-1)).toMatchObject({ index: 0, align: "center", behavior: "smooth" })
600+
601+
// Once at the oldest checkpoint, additional clicks keep targeting it.
602+
await act(async () => {
603+
;(checkpointButton as HTMLButtonElement).click()
604+
})
605+
expect(harness.scrollToIndexArgs.at(-1)).toMatchObject({ index: 0, align: "center", behavior: "smooth" })
606+
})
506607
})

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

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

24-
export const CheckpointMenu = ({ ts, commitHash, checkpoint, onOpenChange }: CheckpointMenuProps) => {
25+
export const CheckpointMenu = ({
26+
ts,
27+
commitHash,
28+
checkpoint,
29+
onOpenChange,
30+
onJumpToPreviousCheckpoint,
31+
}: CheckpointMenuProps) => {
2532
const { t } = useTranslation()
2633
const [internalRestoreOpen, setInternalRestoreOpen] = useState(false)
2734
const [restoreConfirming, setRestoreConfirming] = useState(false)
@@ -165,6 +172,16 @@ export const CheckpointMenu = ({ ts, commitHash, checkpoint, onOpenChange }: Che
165172
</div>
166173
</PopoverContent>
167174
</Popover>
175+
<StandardTooltip content={t("chat:scrollToLatestCheckpoint")}>
176+
<Button
177+
variant="ghost"
178+
size="icon"
179+
onClick={onJumpToPreviousCheckpoint}
180+
data-testid="jump-previous-checkpoint-btn"
181+
aria-label={t("chat:scrollToLatestCheckpoint")}>
182+
<span className="codicon codicon-chevron-up" />
183+
</Button>
184+
</StandardTooltip>
168185
<Popover open={moreOpen} onOpenChange={(open) => setMoreOpen(open)} data-testid="more-popover">
169186
<StandardTooltip content={t("chat:task.seeMore")}>
170187
<PopoverTrigger asChild>

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,15 @@ type CheckpointSavedProps = {
1111
commitHash: string
1212
currentHash?: string
1313
checkpoint?: Record<string, unknown>
14+
onJumpToPreviousCheckpoint?: () => void
1415
}
1516

16-
export const CheckpointSaved = ({ checkpoint, currentHash, ...props }: CheckpointSavedProps) => {
17+
export const CheckpointSaved = ({
18+
checkpoint,
19+
currentHash,
20+
onJumpToPreviousCheckpoint,
21+
...props
22+
}: CheckpointSavedProps) => {
1723
const { t } = useTranslation()
1824
const isCurrent = currentHash === props.commitHash
1925
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
@@ -100,6 +106,7 @@ export const CheckpointSaved = ({ checkpoint, currentHash, ...props }: Checkpoin
100106
commitHash={props.commitHash}
101107
checkpoint={metadata}
102108
onOpenChange={handlePopoverOpenChange}
109+
onJumpToPreviousCheckpoint={onJumpToPreviousCheckpoint}
103110
/>
104111
</div>
105112
</div>

0 commit comments

Comments
 (0)