Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
20 changes: 19 additions & 1 deletion src/esbuild.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ import { copyPaths, copyWasms, copyLocales, setupLocaleWatcher } from "@roo-code
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

async function removeDirWithRetries(dirPath, retries = 5, retryDelayMs = 200) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
await fs.promises.rm(dirPath, { recursive: true, force: true })
return
} catch (error) {
const isRetryable = error?.code === "ENOTEMPTY" || error?.code === "EBUSY" || error?.code === "EPERM"
const isLastAttempt = attempt === retries

if (!isRetryable || isLastAttempt) {
throw error
}

await new Promise((resolve) => globalThis.setTimeout(resolve, retryDelayMs * (attempt + 1)))
}
}
}

async function main() {
const name = "extension"
const production = process.argv.includes("--production")
Expand All @@ -36,7 +54,7 @@ async function main() {

if (fs.existsSync(distDir)) {
console.log(`[${name}] Cleaning dist directory: ${distDir}`)
fs.rmSync(distDir, { recursive: true, force: true })
await removeDirWithRetries(distDir)
}

/**
Expand Down
3 changes: 3 additions & 0 deletions webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ interface ChatRowProps {
isFollowUpAutoApprovalPaused?: boolean
editable?: boolean
hasCheckpoint?: boolean
onJumpToPreviousCheckpoint?: () => void
}

// eslint-disable-next-line @typescript-eslint/no-empty-object-type
Expand Down Expand Up @@ -177,6 +178,7 @@ export const ChatRowContent = ({
onBatchFileResponse,
isFollowUpAnswered,
isFollowUpAutoApprovalPaused,
onJumpToPreviousCheckpoint,
}: ChatRowContentProps) => {
const { t, i18n } = useTranslation()

Expand Down Expand Up @@ -1341,6 +1343,7 @@ export const ChatRowContent = ({
commitHash={message.text!}
currentHash={currentCheckpoint}
checkpoint={message.checkpoint}
onJumpToPreviousCheckpoint={onJumpToPreviousCheckpoint}
/>
)
case "condense_context":
Expand Down
71 changes: 63 additions & 8 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1261,6 +1261,23 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
return result
}, [isCondensing, visibleMessages])

const checkpointIndices = useMemo(() => {
const indices: number[] = []
for (let i = 0; i < groupedMessages.length; i++) {
if (groupedMessages[i]?.say === "checkpoint_saved") {
indices.push(i)
}
}
return indices
}, [groupedMessages])

const hasLatestCheckpoint = checkpointIndices.length > 0
const checkpointJumpCursorRef = useRef<number | null>(null)

useEffect(() => {
checkpointJumpCursorRef.current = null
}, [task?.ts, checkpointIndices])

// Scroll lifecycle is managed by a dedicated hook to keep ChatView focused
// on message handling and UI orchestration.
const {
Expand Down Expand Up @@ -1394,6 +1411,29 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
vscode.postMessage({ type: "cancelAutoApproval" })
}, [])

const handleScrollToBottomAndResetCheckpointCursor = useCallback(() => {
checkpointJumpCursorRef.current = null
handleScrollToBottomClick()
}, [handleScrollToBottomClick])

const handleScrollToLatestCheckpoint = useCallback(() => {
if (checkpointIndices.length === 0) {
return
}

const previousCursor = checkpointJumpCursorRef.current
const nextCursor = previousCursor === null ? checkpointIndices.length - 1 : Math.max(0, previousCursor - 1)
const nextCheckpointIndex = checkpointIndices[nextCursor]
checkpointJumpCursorRef.current = nextCursor

enterUserBrowsingHistory("keyboard-nav-up")
virtuosoRef.current?.scrollToIndex({
index: nextCheckpointIndex,
align: "center",
behavior: "smooth",
})
}, [checkpointIndices, enterUserBrowsingHistory])

const itemContent = useCallback(
(index: number, messageOrGroup: ClineMessage) => {
const hasCheckpoint = modifiedMessages.some((message) => message.say === "checkpoint_saved")
Expand Down Expand Up @@ -1430,6 +1470,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
})()
}
hasCheckpoint={hasCheckpoint}
onJumpToPreviousCheckpoint={handleScrollToLatestCheckpoint}
/>
)
},
Expand All @@ -1447,6 +1488,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
isFollowUpAutoApprovalPaused,
enableButtons,
primaryButtonText,
handleScrollToLatestCheckpoint,
],
)

Expand Down Expand Up @@ -1641,14 +1683,27 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
showScrollToBottom ? "opacity-100" : enableButtons ? "opacity-100" : "opacity-50"
}`}>
{showScrollToBottom ? (
<StandardTooltip content={t("chat:scrollToBottom")}>
<Button
variant="secondary"
className="flex-[2]"
onClick={handleScrollToBottomClick}>
<span className="codicon codicon-chevron-down"></span>
</Button>
</StandardTooltip>
<>
<StandardTooltip content={t("chat:scrollToBottom")}>
<Button
variant="secondary"
className={hasLatestCheckpoint ? "flex-1 mr-[6px]" : "flex-[2]"}
onClick={handleScrollToBottomAndResetCheckpointCursor}>
<span className="codicon codicon-chevron-down"></span>
</Button>
</StandardTooltip>
{hasLatestCheckpoint && (
<StandardTooltip content={t("chat:scrollToLatestCheckpoint")}>
<Button
variant="secondary"
className="flex-1 ml-[6px]"
onClick={handleScrollToLatestCheckpoint}
aria-label={t("chat:scrollToLatestCheckpoint")}>
<span className="codicon codicon-history"></span>
</Button>
</StandardTooltip>
)}
</>
) : (
<>
{primaryButtonText && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ interface MockVirtuosoProps {

interface VirtuosoHarnessState {
scrollCalls: number
scrollToIndexArgs: Array<{
index: number | "LAST"
align?: "end" | "start" | "center"
behavior?: "auto" | "smooth"
}>
atBottomAfterCalls: number
signalDelayMs: number
emitFalseOnDataChange: boolean
Expand All @@ -54,6 +59,7 @@ interface VirtuosoHarnessState {

const harness = vi.hoisted<VirtuosoHarnessState>(() => ({
scrollCalls: 0,
scrollToIndexArgs: [],
atBottomAfterCalls: Number.POSITIVE_INFINITY,
signalDelayMs: 20,
emitFalseOnDataChange: true,
Expand Down Expand Up @@ -152,8 +158,9 @@ vi.mock("react-virtuoso", () => {
}

useImperativeHandle(ref, () => ({
scrollToIndex: () => {
scrollToIndex: (options) => {
harness.scrollCalls += 1
harness.scrollToIndexArgs.push(options)
const reachedBottom = harness.scrollCalls >= harness.atBottomAfterCalls
const timeoutId = window.setTimeout(() => {
atBottomRef.current?.(reachedBottom)
Expand Down Expand Up @@ -215,6 +222,23 @@ const buildMessages = (baseTs: number): ClineMessage[] => [
{ type: "say", say: "text", ts: baseTs + 2, text: "row-2" },
]

const buildMessagesWithCheckpoint = (baseTs: number): ClineMessage[] => [
{ type: "say", say: "text", ts: baseTs, text: "task" },
{ type: "say", say: "text", ts: baseTs + 1, text: "row-1" },
{ type: "say", say: "checkpoint_saved", ts: baseTs + 2, text: "checkpoint-1" },
{ type: "say", say: "text", ts: baseTs + 3, text: "row-2" },
]

const buildMessagesWithMultipleCheckpoints = (baseTs: number): ClineMessage[] => [
{ type: "say", say: "text", ts: baseTs, text: "task" },
{ type: "say", say: "checkpoint_saved", ts: baseTs + 1, text: "checkpoint-1" },
{ type: "say", say: "text", ts: baseTs + 2, text: "row-2" },
{ type: "say", say: "checkpoint_saved", ts: baseTs + 3, text: "checkpoint-2" },
{ type: "say", say: "text", ts: baseTs + 4, text: "row-4" },
{ type: "say", say: "checkpoint_saved", ts: baseTs + 5, text: "checkpoint-3" },
{ type: "say", say: "text", ts: baseTs + 6, text: "row-6" },
]

const resolveFollowOutput = (isAtBottom: boolean): "auto" | false => {
const followOutput = harness.followOutput
if (typeof followOutput === "function") {
Expand Down Expand Up @@ -254,19 +278,19 @@ const renderView = () =>
</ExtensionStateContextProvider>,
)

const hydrate = async (atBottomAfterCalls: number) => {
const hydrate = async (atBottomAfterCalls: number, clineMessages = buildMessages(Date.now() - 3_000)) => {
harness.atBottomAfterCalls = atBottomAfterCalls
renderView()
await act(async () => {
await Promise.resolve()
})
await act(async () => {
postState(buildMessages(Date.now() - 3_000))
postState(clineMessages)
})
await waitFor(() => {
const list = document.querySelector("[data-testid='virtuoso-item-list']")
expect(list).toBeTruthy()
expect(list?.getAttribute("data-count")).toBe("2")
expect(list?.getAttribute("data-count")).toBe(String(Math.max(0, clineMessages.length - 1)))
})
}

Expand Down Expand Up @@ -317,9 +341,19 @@ const getScrollToBottomButton = (): HTMLButtonElement => {
return button
}

const getScrollToCheckpointButton = (): HTMLButtonElement => {
const button = document.querySelector("button[aria-label='chat:scrollToLatestCheckpoint']")
if (!(button instanceof HTMLButtonElement)) {
throw new Error("Expected scroll-to-checkpoint button")
}

return button
}

describe("ChatView scroll behavior regression coverage", () => {
beforeEach(() => {
harness.scrollCalls = 0
harness.scrollToIndexArgs = []
harness.atBottomAfterCalls = Number.POSITIVE_INFINITY
harness.signalDelayMs = 20
harness.emitFalseOnDataChange = true
Expand Down Expand Up @@ -503,4 +537,71 @@ describe("ChatView scroll behavior regression coverage", () => {
})
await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeNull(), { timeout: 1_200 })
})

it("shows jump-to-checkpoint button and scrolls to latest checkpoint", async () => {
await hydrate(2, buildMessagesWithCheckpoint(Date.now() - 3_000))
await waitForCalls(2)
await waitForCallsSettled()

await act(async () => {
fireEvent.keyDown(window, { key: "PageUp" })
})

await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), {
timeout: 1_200,
})

const checkpointButton = document.querySelector("button[aria-label='chat:scrollToLatestCheckpoint']")
expect(checkpointButton).toBeInstanceOf(HTMLButtonElement)

const callsBeforeClick = harness.scrollCalls

await act(async () => {
;(checkpointButton as HTMLButtonElement).click()
})

expect(harness.scrollCalls).toBe(callsBeforeClick + 1)
expect(harness.scrollToIndexArgs.at(-1)).toMatchObject({
index: 1,
align: "center",
behavior: "smooth",
})
})

it("repeated checkpoint clicks step backward through previous checkpoints", async () => {
await hydrate(2, buildMessagesWithMultipleCheckpoints(Date.now() - 3_000))
await waitForCalls(2)
await waitForCallsSettled()

await act(async () => {
fireEvent.keyDown(window, { key: "PageUp" })
})

await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), {
timeout: 1_200,
})

const checkpointButton = getScrollToCheckpointButton()

await act(async () => {
;(checkpointButton as HTMLButtonElement).click()
})
expect(harness.scrollToIndexArgs.at(-1)).toMatchObject({ index: 4, align: "center", behavior: "smooth" })

await act(async () => {
;(checkpointButton as HTMLButtonElement).click()
})
expect(harness.scrollToIndexArgs.at(-1)).toMatchObject({ index: 2, align: "center", behavior: "smooth" })

await act(async () => {
;(checkpointButton as HTMLButtonElement).click()
})
expect(harness.scrollToIndexArgs.at(-1)).toMatchObject({ index: 0, align: "center", behavior: "smooth" })

// Once at the oldest checkpoint, additional clicks keep targeting it.
await act(async () => {
;(checkpointButton as HTMLButtonElement).click()
})
expect(harness.scrollToIndexArgs.at(-1)).toMatchObject({ index: 0, align: "center", behavior: "smooth" })
})
})
19 changes: 18 additions & 1 deletion 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
onJumpToPreviousCheckpoint?: () => void
}
type CheckpointMenuControlledProps = {
onOpenChange: (open: boolean) => void
Expand All @@ -21,7 +22,13 @@ type CheckpointMenuUncontrolledProps = {
}
type CheckpointMenuProps = CheckpointMenuBaseProps & (CheckpointMenuControlledProps | CheckpointMenuUncontrolledProps)

export const CheckpointMenu = ({ ts, commitHash, checkpoint, onOpenChange }: CheckpointMenuProps) => {
export const CheckpointMenu = ({
ts,
commitHash,
checkpoint,
onOpenChange,
onJumpToPreviousCheckpoint,
}: CheckpointMenuProps) => {
const { t } = useTranslation()
const [internalRestoreOpen, setInternalRestoreOpen] = useState(false)
const [restoreConfirming, setRestoreConfirming] = useState(false)
Expand Down Expand Up @@ -165,6 +172,16 @@ export const CheckpointMenu = ({ ts, commitHash, checkpoint, onOpenChange }: Che
</div>
</PopoverContent>
</Popover>
<StandardTooltip content={t("chat:scrollToLatestCheckpoint")}>
<Button
variant="ghost"
size="icon"
onClick={onJumpToPreviousCheckpoint}
data-testid="jump-previous-checkpoint-btn"
aria-label={t("chat:scrollToLatestCheckpoint")}>
<span className="codicon codicon-chevron-up" />
</Button>
</StandardTooltip>
<Popover open={moreOpen} onOpenChange={(open) => setMoreOpen(open)} data-testid="more-popover">
<StandardTooltip content={t("chat:task.seeMore")}>
<PopoverTrigger asChild>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@ type CheckpointSavedProps = {
commitHash: string
currentHash?: string
checkpoint?: Record<string, unknown>
onJumpToPreviousCheckpoint?: () => void
}

export const CheckpointSaved = ({ checkpoint, currentHash, ...props }: CheckpointSavedProps) => {
export const CheckpointSaved = ({
checkpoint,
currentHash,
onJumpToPreviousCheckpoint,
...props
}: CheckpointSavedProps) => {
const { t } = useTranslation()
const isCurrent = currentHash === props.commitHash
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
Expand Down Expand Up @@ -100,6 +106,7 @@ export const CheckpointSaved = ({ checkpoint, currentHash, ...props }: Checkpoin
commitHash={props.commitHash}
checkpoint={metadata}
onOpenChange={handlePopoverOpenChange}
onJumpToPreviousCheckpoint={onJumpToPreviousCheckpoint}
/>
</div>
</div>
Expand Down
Loading
Loading