Skip to content
Draft
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
17 changes: 17 additions & 0 deletions webview-ui/playwright/gallery/stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,23 @@ export const stories: Record<string, Story> = {
await import("@/components/settings/providers/__tests__/OpenAICompatible.visual.fixture")
return <OpenAICompatibleAzureFixture />
},
"previous-user-message-button": async () => {
const [{ PreviousUserMessageButton }, { TooltipProvider }] = await Promise.all([
import("@/components/chat/PreviousUserMessageButton"),
import("@/components/ui/tooltip"),
])
return (
<TooltipProvider>
<div className="flex w-72 bg-vscode-editor-background p-3">
<PreviousUserMessageButton
title="Jump to previous message you sent"
className="flex-1"
onClick={() => undefined}
/>
</div>
</TooltipProvider>
)
},
"rendered-content-contrast": async () => {
const [{ AppProviders }, { RenderedContentContrastFixture }] = await Promise.all([
import("../AppProviders"),
Expand Down
87 changes: 71 additions & 16 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { CheckpointWarning } from "./CheckpointWarning"
import { QueuedMessages } from "./QueuedMessages"
import { WorktreeSelector } from "./WorktreeSelector"
import FileChangesPanel from "./FileChangesPanel"
import { PreviousUserMessageButton } from "./PreviousUserMessageButton"
import { useScrollLifecycle } from "@src/hooks/useScrollLifecycle"

export interface ChatViewProps {
Expand Down Expand Up @@ -1334,14 +1335,30 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}
return indices
}, [groupedMessages])
const userMessageIndices = useMemo(() => {
const indices: number[] = []
for (let i = 0; i < groupedMessages.length; i++) {
const message = groupedMessages[i]
if (message?.type === "say" && message.say === "user_feedback") {
indices.push(i)
}
}
return indices
}, [groupedMessages])

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

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

useEffect(() => {
userMessageJumpCursorRef.current = null
}, [task?.ts, userMessageIndices.length])

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

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

const handleScrollToPreviousUserMessage = useCallback(() => {
if (userMessageIndices.length === 0 || userMessageJumpCursorRef.current === 0) {
return
}

const previousCursor = userMessageJumpCursorRef.current
const nextCursor = previousCursor === null ? userMessageIndices.length - 1 : previousCursor - 1
userMessageJumpCursorRef.current = nextCursor

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

const handleScrollToLatestCheckpoint = useCallback(() => {
if (checkpointIndices.length === 0) {
return
Expand Down Expand Up @@ -1642,7 +1677,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}

const hasApprovalButtons = Boolean(primaryButtonText || secondaryButtonText)
const areButtonsVisible = showScrollToBottom || hasApprovalButtons
const areButtonsVisible = showScrollToBottom || hasApprovalButtons || hasUserMessages
const currentTaskAggregatedCosts = currentTaskId ? aggregatedCostsMap.get(currentTaskId) : undefined

return (
Expand Down Expand Up @@ -1734,38 +1769,58 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
{areButtonsVisible && (
<div
className={`flex h-9 items-center mb-1 px-[15px] ${
showScrollToBottom ? "opacity-100" : enableButtons ? "opacity-100" : "opacity-50"
showScrollToBottom || hasUserMessages
? "opacity-100"
: enableButtons
? "opacity-100"
: "opacity-50"
}`}>
{showScrollToBottom && !hasApprovalButtons ? (
<>
<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>
{!hasApprovalButtons ? (
<div className="flex flex-1 gap-3">
{hasUserMessages && (
<PreviousUserMessageButton
title={t("chat:jumpToPreviousUserMessage")}
className="flex-1"
onClick={handleScrollToPreviousUserMessage}
/>
)}
{showScrollToBottom && (
<StandardTooltip content={t("chat:scrollToBottom")}>
<Button
variant="secondary"
className="flex-1"
onClick={handleScrollToBottomAndResetNavigationCursors}>
<span className="codicon codicon-chevron-down"></span>
</Button>
</StandardTooltip>
)}
{hasLatestCheckpoint && (
<StandardTooltip content={t("chat:scrollToLatestCheckpoint")}>
<Button
variant="secondary"
className="flex-1 ml-[6px]"
className="flex-1"
onClick={handleScrollToLatestCheckpoint}
aria-label={t("chat:scrollToLatestCheckpoint")}>
<span className="codicon codicon-history"></span>
</Button>
</StandardTooltip>
)}
</>
</div>
) : (
<>
{hasUserMessages && (
<PreviousUserMessageButton
title={t("chat:jumpToPreviousUserMessage")}
className="w-9 shrink-0 mr-[6px]"
onClick={handleScrollToPreviousUserMessage}
/>
)}
{showScrollToBottom && (
<StandardTooltip content={t("chat:scrollToBottom")}>
<Button
variant="secondary"
className="w-9 shrink-0 mr-[6px]"
onClick={handleScrollToBottomAndResetCheckpointCursor}>
onClick={handleScrollToBottomAndResetNavigationCursors}>
<span className="codicon codicon-chevron-down"></span>
</Button>
</StandardTooltip>
Expand Down
18 changes: 18 additions & 0 deletions webview-ui/src/components/chat/PreviousUserMessageButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Button, StandardTooltip } from "@src/components/ui"

interface PreviousUserMessageButtonProps {
title: string
className?: string
onClick: () => void
}

export const PreviousUserMessageButton = ({ title, className, onClick }: PreviousUserMessageButtonProps) => (
<StandardTooltip content={title}>
<Button variant="secondary" className={className} onClick={onClick} aria-label={title}>
<span className="flex items-center gap-0.5">
<span className="codicon codicon-account" />
<span className="codicon codicon-arrow-up text-[10px] -ml-0.5" />
</span>
</Button>
</StandardTooltip>
)
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,15 @@ const buildMessagesWithMultipleCheckpoints = (baseTs: number): ClineMessage[] =>
{ type: "say", say: "text", ts: baseTs + 6, text: "row-6" },
]

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

const resolveFollowOutput = (isAtBottom: boolean): "auto" | false => {
const followOutput = harness.followOutput
if (typeof followOutput === "function") {
Expand Down Expand Up @@ -361,6 +370,15 @@ const getScrollToCheckpointButton = (): HTMLButtonElement => {
return button
}

const getPreviousUserMessageButton = (): HTMLButtonElement => {
const button = document.querySelector("button[aria-label='chat:jumpToPreviousUserMessage']")
if (!(button instanceof HTMLButtonElement)) {
throw new Error("Expected jump-to-previous-user-message button")
}

return button
}

const getButtonByText = (text: string): HTMLButtonElement => {
const button = Array.from(document.querySelectorAll("button")).find((candidate) => candidate.textContent === text)
if (!(button instanceof HTMLButtonElement)) {
Expand Down Expand Up @@ -631,6 +649,68 @@ describe("ChatView scroll behavior regression coverage", () => {
await expectChevronHidden()
})

it("only shows previous-user-message navigation when follow-up messages exist", async () => {
await hydrate(2)
await waitForCalls(2)
await waitForCallsSettled()

expect(document.querySelector("button[aria-label='chat:jumpToPreviousUserMessage']")).toBeNull()

await act(async () => {
postState(buildMessagesWithUserFeedback(Date.now() - 3_000))
})
await flushEffects()

expect(getPreviousUserMessageButton()).toBeVisible()
})

it("steps backward through user messages and stops at the oldest", async () => {
await hydrate(2, buildMessagesWithUserFeedback(Date.now() - 3_000))
await waitForCalls(2)
await waitForCallsSettled()

const button = getPreviousUserMessageButton()
const callsBeforeClick = harness.scrollCalls

await act(async () => {
button.click()
})
expect(harness.scrollToIndexArgs.at(-1)).toMatchObject({ index: 3, align: "center", behavior: "smooth" })

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

await act(async () => {
button.click()
})
expect(harness.scrollCalls).toBe(callsBeforeClick + 2)
})

it("resets previous-user-message navigation when a new user message arrives", async () => {
const baseTs = Date.now() - 3_000
const initialMessages = buildMessagesWithUserFeedback(baseTs)
await hydrate(2, initialMessages)
await waitForCalls(2)
await waitForCallsSettled()

await act(async () => {
getPreviousUserMessageButton().click()
getPreviousUserMessageButton().click()
})

await act(async () => {
postState([...initialMessages, { type: "say", say: "user_feedback", ts: baseTs + 6, text: "feedback-3" }])
})
await flushEffects()

await act(async () => {
getPreviousUserMessageButton().click()
})
expect(harness.scrollToIndexArgs.at(-1)).toMatchObject({ index: 5, align: "center", behavior: "smooth" })
})

it("shows jump-to-checkpoint button and scrolls to latest checkpoint", async () => {
await hydrate(2, buildMessagesWithCheckpoint(Date.now() - 3_000))
await waitForCalls(2)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { expect, test } from "../../../../playwright/coverage-fixture"
import { mountedStory } from "../../../../playwright/mounted-story"

const themes = [
{ name: "dark", bodyClass: "vscode-dark", themeId: "Default Dark Modern" },
{ name: "light", bodyClass: "vscode-light", themeId: "Default Light Modern" },
] as const

for (const theme of themes) {
test(`renders previous-user-message navigation in the VS Code ${theme.name} theme`, async ({ mount, page }) => {
const component = mountedStory(await mount("previous-user-message-button"))
await page.evaluate(({ bodyClass, themeId }) => {
document.documentElement.className = bodyClass
document.body.className = bodyClass
document.body.dataset.vscodeThemeId = themeId
}, theme)

await component.evaluate(async () => {
await document.fonts.ready
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
})

await expect(component).toHaveScreenshot(`previous-user-message-button-${theme.name}.png`)
})
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/ca/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/de/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/en/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
},
"scrollToBottom": "Scroll to bottom of chat",
"scrollToLatestCheckpoint": "Scroll to previous checkpoint",
"jumpToPreviousUserMessage": "Jump to previous message you sent",
"about": "Zoo is a whole AI dev team in your editor",
"docs": "Check our <DocsLink>docs</DocsLink> to get started",
"onboarding": "What would you like to do?",
Expand Down
1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/es/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/fr/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/hi/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/id/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/it/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/ja/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading