Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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 @@ -52,6 +52,7 @@ interface ChatRowProps {
onSuggestionClick?: (suggestion: SuggestionItem, event?: React.MouseEvent) => void
onBatchFileResponse?: (response: { [key: string]: boolean }) => void
onFollowUpUnmount?: () => void
isFollowUpAnswered?: boolean
}

// eslint-disable-next-line @typescript-eslint/no-empty-object-type
Expand Down Expand Up @@ -102,6 +103,7 @@ export const ChatRowContent = ({
onSuggestionClick,
onFollowUpUnmount,
onBatchFileResponse,
isFollowUpAnswered,
}: ChatRowContentProps) => {
const { t } = useTranslation()
const { mcpServers, alwaysAllowMcp, currentCheckpoint } = useExtensionState()
Expand Down Expand Up @@ -1219,6 +1221,7 @@ export const ChatRowContent = ({
onSuggestionClick={onSuggestionClick}
ts={message?.ts}
onUnmount={onFollowUpUnmount}
isAnswered={isFollowUpAnswered}
/>
</>
)
Expand Down
35 changes: 34 additions & 1 deletion webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}),
)
const autoApproveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const [followUpAnswered, setFollowUpAnswered] = useState<Set<number>>(new Set())

const clineAskRef = useRef(clineAsk)
useEffect(() => {
Expand Down Expand Up @@ -415,6 +416,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
useEffect(() => {
setExpandedRows({})
everVisibleMessagesTsRef.current.clear() // Clear for new task
setFollowUpAnswered(new Set()) // Clear follow-up answered state for new task
}, [task?.ts])

useEffect(() => {
Expand Down Expand Up @@ -487,6 +489,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}, [modifiedMessages, clineAsk, enableButtons, primaryButtonText])

const handleChatReset = useCallback(() => {
// Clear any pending auto-approval timeout to prevent race conditions
if (autoApproveTimeoutRef.current) {
clearTimeout(autoApproveTimeoutRef.current)
autoApproveTimeoutRef.current = null
}

// Only reset message-specific state, preserving mode.
setInputValue("")
setSendingDisabled(true)
Expand All @@ -507,6 +515,21 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
if (messagesRef.current.length === 0) {
vscode.postMessage({ type: "newTask", text, images })
} else if (clineAskRef.current) {
// Clear auto-approval timeout when user sends a message for follow-up questions
// This ensures the countdown timer is properly dismounted when users submit custom responses
if (clineAskRef.current === "followup") {
if (autoApproveTimeoutRef.current) {
clearTimeout(autoApproveTimeoutRef.current)
autoApproveTimeoutRef.current = null
}

// Mark the current follow-up question as answered
const lastFollowUpMessage = messagesRef.current.findLast((msg) => msg.ask === "followup")
if (lastFollowUpMessage) {
setFollowUpAnswered((prev) => new Set(prev).add(lastFollowUpMessage.ts))
}
}

// Use clineAskRef.current
switch (
clineAskRef.current // Use clineAskRef.current
Expand Down Expand Up @@ -1214,6 +1237,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro

const handleSuggestionClickInRow = useCallback(
(suggestion: SuggestionItem, event?: React.MouseEvent) => {
// Mark the current follow-up question as answered when a suggestion is clicked
if (clineAsk === "followup" && !event?.shiftKey) {
const lastFollowUpMessage = messagesRef.current.findLast((msg) => msg.ask === "followup")
if (lastFollowUpMessage) {
setFollowUpAnswered((prev) => new Set(prev).add(lastFollowUpMessage.ts))
}
}

// Check if we need to switch modes
if (suggestion.mode) {
// Only switch modes if it's a manual click (event exists) or auto-approval is allowed
Expand All @@ -1233,7 +1264,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
handleSendMessage(suggestion.answer, [])
}
},
[handleSendMessage, setInputValue, switchToMode, alwaysAllowModeSwitch],
[handleSendMessage, setInputValue, switchToMode, alwaysAllowModeSwitch, clineAsk],
)

const handleBatchFileResponse = useCallback((response: { [key: string]: boolean }) => {
Expand Down Expand Up @@ -1286,6 +1317,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
onSuggestionClick={handleSuggestionClickInRow} // This was already stabilized
onBatchFileResponse={handleBatchFileResponse}
onFollowUpUnmount={handleFollowUpUnmount}
isFollowUpAnswered={followUpAnswered.has(messageOrGroup.ts)}
/>
)
},
Expand All @@ -1299,6 +1331,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
handleSuggestionClickInRow,
handleBatchFileResponse,
handleFollowUpUnmount,
followUpAnswered,
],
)

Expand Down
21 changes: 18 additions & 3 deletions webview-ui/src/components/chat/FollowUpSuggest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,16 @@ interface FollowUpSuggestProps {
onSuggestionClick?: (suggestion: SuggestionItem, event?: React.MouseEvent) => void
ts: number
onUnmount?: () => void
isAnswered?: boolean
}

export const FollowUpSuggest = ({ suggestions = [], onSuggestionClick, ts = 1, onUnmount }: FollowUpSuggestProps) => {
export const FollowUpSuggest = ({
suggestions = [],
onSuggestionClick,
ts = 1,
onUnmount,
isAnswered = false,
}: FollowUpSuggestProps) => {
const { autoApprovalEnabled, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs } = useExtensionState()
const [countdown, setCountdown] = useState<number | null>(null)
const [suggestionSelected, setSuggestionSelected] = useState(false)
Expand All @@ -26,7 +33,14 @@ export const FollowUpSuggest = ({ suggestions = [], onSuggestionClick, ts = 1, o
// Start countdown timer when auto-approval is enabled for follow-up questions
useEffect(() => {
// Only start countdown if auto-approval is enabled for follow-up questions and no suggestion has been selected
if (autoApprovalEnabled && alwaysAllowFollowupQuestions && suggestions.length > 0 && !suggestionSelected) {
// Also stop countdown if the question has been answered
if (
autoApprovalEnabled &&
alwaysAllowFollowupQuestions &&
suggestions.length > 0 &&
!suggestionSelected &&
!isAnswered
) {
// Start with the configured timeout in seconds
const timeoutMs =
typeof followupAutoApproveTimeoutMs === "number" && !isNaN(followupAutoApproveTimeoutMs)
Expand Down Expand Up @@ -64,6 +78,7 @@ export const FollowUpSuggest = ({ suggestions = [], onSuggestionClick, ts = 1, o
followupAutoApproveTimeoutMs,
suggestionSelected,
onUnmount,
isAnswered,
])
const handleSuggestionClick = useCallback(
(suggestion: SuggestionItem, event: React.MouseEvent) => {
Expand Down Expand Up @@ -100,7 +115,7 @@ export const FollowUpSuggest = ({ suggestions = [], onSuggestionClick, ts = 1, o
onClick={(event) => handleSuggestionClick(suggestion, event)}
aria-label={suggestion.answer}>
{suggestion.answer}
{isFirstSuggestion && countdown !== null && !suggestionSelected && (
{isFirstSuggestion && countdown !== null && !suggestionSelected && !isAnswered && (
<span
className="ml-2 px-1.5 py-0.5 text-xs rounded-full bg-vscode-badge-background text-vscode-badge-foreground"
title={t("chat:followUpSuggest.autoSelectCountdown", { count: countdown })}>
Expand Down
200 changes: 200 additions & 0 deletions webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { render, screen } from "@testing-library/react"
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { FollowUpSuggest } from "../FollowUpSuggest"
import { ExtensionStateContext, ExtensionStateContextType } from "@src/context/ExtensionStateContext"
import { TooltipProvider } from "@radix-ui/react-tooltip"

// Mock the translation hook
vi.mock("@src/i18n/TranslationContext", () => ({
TranslationProvider: ({ children }: { children: React.ReactNode }) => children,
useAppTranslation: () => ({
t: (key: string, options?: any) => {
if (key === "chat:followUpSuggest.countdownDisplay" && options?.count !== undefined) {
return `${options.count}s`
}
if (key === "chat:followUpSuggest.autoSelectCountdown" && options?.count !== undefined) {
return `Auto-selecting in ${options.count} seconds`
}
if (key === "chat:followUpSuggest.copyToInput") {
return "Copy to input"
}
return key
},
}),
}))

// Mock the extension state
const createMockExtensionState = (overrides?: Partial<ExtensionStateContextType>): ExtensionStateContextType =>
({
version: "1.0.0",
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
soundEnabled: false,
soundVolume: 0.5,
ttsEnabled: false,
ttsSpeed: 1.0,
diffEnabled: false,
enableCheckpoints: true,
fuzzyMatchThreshold: 1.0,
language: "en",
writeDelayMs: 1000,
browserViewportSize: "900x600",
screenshotQuality: 75,
terminalOutputLineLimit: 500,
terminalShellIntegrationTimeout: 4000,
mcpEnabled: true,
enableMcpServerCreation: false,
alwaysApproveResubmit: false,
requestDelaySeconds: 5,
currentApiConfigName: "default",
listApiConfigMeta: [],
mode: "code",
customModePrompts: {},
customSupportPrompts: {},
experiments: {},
enhancementApiConfigId: "",
condensingApiConfigId: "",
customCondensingPrompt: "",
hasOpenedModeSelector: false,
autoApprovalEnabled: true,
alwaysAllowFollowupQuestions: true,
followupAutoApproveTimeoutMs: 3000, // 3 seconds for testing
customModes: [],
maxOpenTabsContext: 20,
maxWorkspaceFiles: 200,
cwd: "",
browserToolEnabled: true,
telemetrySetting: "unset",
showRooIgnoredFiles: true,
renderContext: "sidebar",
maxReadFileLine: -1,
pinnedApiConfigs: {},
didHydrateState: true,
showWelcome: false,
theme: {},
mcpServers: [],
filePaths: [],
openedTabs: [],
organizationAllowList: { type: "all" },
cloudIsAuthenticated: false,
sharingEnabled: false,
mdmCompliant: true,
autoCondenseContext: false,
autoCondenseContextPercent: 50,
setHasOpenedModeSelector: vi.fn(),
setAlwaysAllowFollowupQuestions: vi.fn(),
setFollowupAutoApproveTimeoutMs: vi.fn(),
setCondensingApiConfigId: vi.fn(),
setCustomCondensingPrompt: vi.fn(),
setPinnedApiConfigs: vi.fn(),
togglePinnedApiConfig: vi.fn(),
setTerminalCompressProgressBar: vi.fn(),
setHistoryPreviewCollapsed: vi.fn(),
setAutoCondenseContext: vi.fn(),
setAutoCondenseContextPercent: vi.fn(),
...overrides,
}) as ExtensionStateContextType

const renderWithProviders = (component: React.ReactElement, stateOverrides?: Partial<ExtensionStateContextType>) => {
const mockState = createMockExtensionState(stateOverrides)

return render(
<ExtensionStateContext.Provider value={mockState}>
<TooltipProvider>{component}</TooltipProvider>
</ExtensionStateContext.Provider>,
)
}

describe("FollowUpSuggest", () => {
const mockSuggestions = [{ answer: "First suggestion" }, { answer: "Second suggestion" }]

const mockOnSuggestionClick = vi.fn()
const mockOnUnmount = vi.fn()

beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})

afterEach(() => {
vi.useRealTimers()
})

it("should display countdown timer when auto-approval is enabled", () => {
renderWithProviders(
<FollowUpSuggest
suggestions={mockSuggestions}
onSuggestionClick={mockOnSuggestionClick}
ts={123}
onUnmount={mockOnUnmount}
/>,
)

// Should show initial countdown (3 seconds)
expect(screen.getByText(/3s/)).toBeInTheDocument()
})

it("should not display countdown timer when isAnswered is true", () => {
renderWithProviders(
<FollowUpSuggest
suggestions={mockSuggestions}
onSuggestionClick={mockOnSuggestionClick}
ts={123}
onUnmount={mockOnUnmount}
isAnswered={true}
/>,
)

// Should not show countdown
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
})

it("should clear interval and call onUnmount when component unmounts", () => {
const { unmount } = renderWithProviders(
<FollowUpSuggest
suggestions={mockSuggestions}
onSuggestionClick={mockOnSuggestionClick}
ts={123}
onUnmount={mockOnUnmount}
/>,
)

// Unmount the component
unmount()

// onUnmount should have been called
expect(mockOnUnmount).toHaveBeenCalled()
})

it("should not show countdown when auto-approval is disabled", () => {
renderWithProviders(
<FollowUpSuggest
suggestions={mockSuggestions}
onSuggestionClick={mockOnSuggestionClick}
ts={123}
onUnmount={mockOnUnmount}
/>,
{ autoApprovalEnabled: false },
)

// Should not show countdown
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
})

it("should not show countdown when alwaysAllowFollowupQuestions is false", () => {
renderWithProviders(
<FollowUpSuggest
suggestions={mockSuggestions}
onSuggestionClick={mockOnSuggestionClick}
ts={123}
onUnmount={mockOnUnmount}
/>,
{ alwaysAllowFollowupQuestions: false },
)

// Should not show countdown
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument()
})
})