Skip to content

Commit 014e8aa

Browse files
committed
feat(chat): add previous checkpoint navigation controls and i18n (RooCodeInc#12139)
Cherry-picked from upstream 2bb8260, with CRC-specific ChatView structure preserved (queue-and-steer, displayedPrimaryButtonText). (cherry picked from commit 2bb8260)
1 parent 4536f8b commit 014e8aa

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
@@ -1417,6 +1417,23 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
14171417
return result
14181418
}, [isCondensing, visibleMessages])
14191419

1420+
const checkpointIndices = useMemo(() => {
1421+
const indices: number[] = []
1422+
for (let i = 0; i < groupedMessages.length; i++) {
1423+
if (groupedMessages[i]?.say === "checkpoint_saved") {
1424+
indices.push(i)
1425+
}
1426+
}
1427+
return indices
1428+
}, [groupedMessages])
1429+
1430+
const hasLatestCheckpoint = checkpointIndices.length > 0
1431+
const checkpointJumpCursorRef = useRef<number | null>(null)
1432+
1433+
useEffect(() => {
1434+
checkpointJumpCursorRef.current = null
1435+
}, [task?.ts, checkpointIndices])
1436+
14201437
// Scroll lifecycle is managed by a dedicated hook to keep ChatView focused
14211438
// on message handling and UI orchestration.
14221439
const {
@@ -1550,6 +1567,29 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
15501567
vscode.postMessage({ type: "cancelAutoApproval" })
15511568
}, [])
15521569

1570+
const handleScrollToBottomAndResetCheckpointCursor = useCallback(() => {
1571+
checkpointJumpCursorRef.current = null
1572+
handleScrollToBottomClick()
1573+
}, [handleScrollToBottomClick])
1574+
1575+
const handleScrollToLatestCheckpoint = useCallback(() => {
1576+
if (checkpointIndices.length === 0) {
1577+
return
1578+
}
1579+
1580+
const previousCursor = checkpointJumpCursorRef.current
1581+
const nextCursor = previousCursor === null ? checkpointIndices.length - 1 : Math.max(0, previousCursor - 1)
1582+
const nextCheckpointIndex = checkpointIndices[nextCursor]
1583+
checkpointJumpCursorRef.current = nextCursor
1584+
1585+
enterUserBrowsingHistory("keyboard-nav-up")
1586+
virtuosoRef.current?.scrollToIndex({
1587+
index: nextCheckpointIndex,
1588+
align: "center",
1589+
behavior: "smooth",
1590+
})
1591+
}, [checkpointIndices, enterUserBrowsingHistory])
1592+
15531593
const itemContent = useCallback(
15541594
(index: number, messageOrGroup: ClineMessage) => {
15551595
const hasCheckpoint = modifiedMessages.some((message) => message.say === "checkpoint_saved")
@@ -1591,6 +1631,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
15911631
})()
15921632
}
15931633
hasCheckpoint={hasCheckpoint}
1634+
onJumpToPreviousCheckpoint={handleScrollToLatestCheckpoint}
15941635
/>
15951636
)
15961637
},
@@ -1609,6 +1650,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
16091650
enableButtons,
16101651
primaryButtonText,
16111652
shouldHideAutoDecisionButtons,
1653+
handleScrollToLatestCheckpoint,
16121654
],
16131655
)
16141656

@@ -1782,14 +1824,27 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
17821824
: "opacity-50"
17831825
}`}>
17841826
{showScrollToBottom ? (
1785-
<StandardTooltip content={t("chat:scrollToBottom")}>
1786-
<Button
1787-
variant="secondary"
1788-
className="flex-[2]"
1789-
onClick={handleScrollToBottomClick}>
1790-
<span className="codicon codicon-chevron-down"></span>
1791-
</Button>
1792-
</StandardTooltip>
1827+
<>
1828+
<StandardTooltip content={t("chat:scrollToBottom")}>
1829+
<Button
1830+
variant="secondary"
1831+
className={hasLatestCheckpoint ? "flex-1 mr-[6px]" : "flex-[2]"}
1832+
onClick={handleScrollToBottomAndResetCheckpointCursor}>
1833+
<span className="codicon codicon-chevron-down"></span>
1834+
</Button>
1835+
</StandardTooltip>
1836+
{hasLatestCheckpoint && (
1837+
<StandardTooltip content={t("chat:scrollToLatestCheckpoint")}>
1838+
<Button
1839+
variant="secondary"
1840+
className="flex-1 ml-[6px]"
1841+
onClick={handleScrollToLatestCheckpoint}
1842+
aria-label={t("chat:scrollToLatestCheckpoint")}>
1843+
<span className="codicon codicon-history"></span>
1844+
</Button>
1845+
</StandardTooltip>
1846+
)}
1847+
</>
17931848
) : (
17941849
<>
17951850
{displayedPrimaryButtonText && (

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
@@ -42,6 +42,11 @@ interface MockVirtuosoProps {
4242

4343
interface VirtuosoHarnessState {
4444
scrollCalls: number
45+
scrollToIndexArgs: Array<{
46+
index: number | "LAST"
47+
align?: "end" | "start" | "center"
48+
behavior?: "auto" | "smooth"
49+
}>
4550
atBottomAfterCalls: number
4651
signalDelayMs: number
4752
emitFalseOnDataChange: boolean
@@ -53,6 +58,7 @@ interface VirtuosoHarnessState {
5358

5459
const harness = vi.hoisted<VirtuosoHarnessState>(() => ({
5560
scrollCalls: 0,
61+
scrollToIndexArgs: [],
5662
atBottomAfterCalls: Number.POSITIVE_INFINITY,
5763
signalDelayMs: 20,
5864
emitFalseOnDataChange: true,
@@ -147,8 +153,9 @@ vi.mock("react-virtuoso", () => {
147153
}
148154

149155
useImperativeHandle(ref, () => ({
150-
scrollToIndex: () => {
156+
scrollToIndex: (options) => {
151157
harness.scrollCalls += 1
158+
harness.scrollToIndexArgs.push(options)
152159
const reachedBottom = harness.scrollCalls >= harness.atBottomAfterCalls
153160
const timeoutId = window.setTimeout(() => {
154161
atBottomRef.current?.(reachedBottom)
@@ -208,6 +215,23 @@ const buildMessages = (baseTs: number): ClineMessage[] => [
208215
{ type: "say", say: "text", ts: baseTs + 2, text: "row-2" },
209216
]
210217

218+
const buildMessagesWithCheckpoint = (baseTs: number): ClineMessage[] => [
219+
{ type: "say", say: "text", ts: baseTs, text: "task" },
220+
{ type: "say", say: "text", ts: baseTs + 1, text: "row-1" },
221+
{ type: "say", say: "checkpoint_saved", ts: baseTs + 2, text: "checkpoint-1" },
222+
{ type: "say", say: "text", ts: baseTs + 3, text: "row-2" },
223+
]
224+
225+
const buildMessagesWithMultipleCheckpoints = (baseTs: number): ClineMessage[] => [
226+
{ type: "say", say: "text", ts: baseTs, text: "task" },
227+
{ type: "say", say: "checkpoint_saved", ts: baseTs + 1, text: "checkpoint-1" },
228+
{ type: "say", say: "text", ts: baseTs + 2, text: "row-2" },
229+
{ type: "say", say: "checkpoint_saved", ts: baseTs + 3, text: "checkpoint-2" },
230+
{ type: "say", say: "text", ts: baseTs + 4, text: "row-4" },
231+
{ type: "say", say: "checkpoint_saved", ts: baseTs + 5, text: "checkpoint-3" },
232+
{ type: "say", say: "text", ts: baseTs + 6, text: "row-6" },
233+
]
234+
211235
const resolveFollowOutput = (isAtBottom: boolean): "auto" | false => {
212236
const followOutput = harness.followOutput
213237
if (typeof followOutput === "function") {
@@ -246,19 +270,19 @@ const renderView = () =>
246270
</ExtensionStateContextProvider>,
247271
)
248272

249-
const hydrate = async (atBottomAfterCalls: number) => {
273+
const hydrate = async (atBottomAfterCalls: number, clineMessages = buildMessages(Date.now() - 3_000)) => {
250274
harness.atBottomAfterCalls = atBottomAfterCalls
251275
renderView()
252276
await act(async () => {
253277
await Promise.resolve()
254278
})
255279
await act(async () => {
256-
postState(buildMessages(Date.now() - 3_000))
280+
postState(clineMessages)
257281
})
258282
await waitFor(() => {
259283
const list = document.querySelector("[data-testid='virtuoso-item-list']")
260284
expect(list).toBeTruthy()
261-
expect(list?.getAttribute("data-count")).toBe("2")
285+
expect(list?.getAttribute("data-count")).toBe(String(Math.max(0, clineMessages.length - 1)))
262286
})
263287
}
264288

@@ -309,9 +333,19 @@ const getScrollToBottomButton = (): HTMLButtonElement => {
309333
return button
310334
}
311335

336+
const getScrollToCheckpointButton = (): HTMLButtonElement => {
337+
const button = document.querySelector("button[aria-label='chat:scrollToLatestCheckpoint']")
338+
if (!(button instanceof HTMLButtonElement)) {
339+
throw new Error("Expected scroll-to-checkpoint button")
340+
}
341+
342+
return button
343+
}
344+
312345
describe("ChatView scroll behavior regression coverage", () => {
313346
beforeEach(() => {
314347
harness.scrollCalls = 0
348+
harness.scrollToIndexArgs = []
315349
harness.atBottomAfterCalls = Number.POSITIVE_INFINITY
316350
harness.signalDelayMs = 20
317351
harness.emitFalseOnDataChange = true
@@ -495,4 +529,71 @@ describe("ChatView scroll behavior regression coverage", () => {
495529
})
496530
await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeNull(), { timeout: 1_200 })
497531
})
532+
533+
it("shows jump-to-checkpoint button and scrolls to latest checkpoint", async () => {
534+
await hydrate(2, buildMessagesWithCheckpoint(Date.now() - 3_000))
535+
await waitForCalls(2)
536+
await waitForCallsSettled()
537+
538+
await act(async () => {
539+
fireEvent.keyDown(window, { key: "PageUp" })
540+
})
541+
542+
await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), {
543+
timeout: 1_200,
544+
})
545+
546+
const checkpointButton = document.querySelector("button[aria-label='chat:scrollToLatestCheckpoint']")
547+
expect(checkpointButton).toBeInstanceOf(HTMLButtonElement)
548+
549+
const callsBeforeClick = harness.scrollCalls
550+
551+
await act(async () => {
552+
;(checkpointButton as HTMLButtonElement).click()
553+
})
554+
555+
expect(harness.scrollCalls).toBe(callsBeforeClick + 1)
556+
expect(harness.scrollToIndexArgs.at(-1)).toMatchObject({
557+
index: 1,
558+
align: "center",
559+
behavior: "smooth",
560+
})
561+
})
562+
563+
it("repeated checkpoint clicks step backward through previous checkpoints", async () => {
564+
await hydrate(2, buildMessagesWithMultipleCheckpoints(Date.now() - 3_000))
565+
await waitForCalls(2)
566+
await waitForCallsSettled()
567+
568+
await act(async () => {
569+
fireEvent.keyDown(window, { key: "PageUp" })
570+
})
571+
572+
await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), {
573+
timeout: 1_200,
574+
})
575+
576+
const checkpointButton = getScrollToCheckpointButton()
577+
578+
await act(async () => {
579+
;(checkpointButton as HTMLButtonElement).click()
580+
})
581+
expect(harness.scrollToIndexArgs.at(-1)).toMatchObject({ index: 4, align: "center", behavior: "smooth" })
582+
583+
await act(async () => {
584+
;(checkpointButton as HTMLButtonElement).click()
585+
})
586+
expect(harness.scrollToIndexArgs.at(-1)).toMatchObject({ index: 2, align: "center", behavior: "smooth" })
587+
588+
await act(async () => {
589+
;(checkpointButton as HTMLButtonElement).click()
590+
})
591+
expect(harness.scrollToIndexArgs.at(-1)).toMatchObject({ index: 0, align: "center", behavior: "smooth" })
592+
593+
// Once at the oldest checkpoint, additional clicks keep targeting it.
594+
await act(async () => {
595+
;(checkpointButton as HTMLButtonElement).click()
596+
})
597+
expect(harness.scrollToIndexArgs.at(-1)).toMatchObject({ index: 0, align: "center", behavior: "smooth" })
598+
})
498599
})

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)