Skip to content

Commit 3f04e1e

Browse files
committed
fix: reset didClickCancel state when streaming ends to prevent re-rendering
- Added logic to reset didClickCancel to false when streaming transitions from true to false - This prevents the chat UI from re-rendering unexpectedly when canceling OpenAI Compatible API requests - Added comprehensive tests to verify the cancel button state is properly managed Fixes #6726
1 parent ea79dfe commit 3f04e1e

File tree

2 files changed

+177
-0
lines changed

2 files changed

+177
-0
lines changed

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,6 +1208,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
12081208
}
12091209
}
12101210

1211+
// Reset didClickCancel when streaming ends
1212+
if (wasStreaming && !isStreaming) {
1213+
setDidClickCancel(false)
1214+
}
1215+
12111216
// Update previous value.
12121217
setWasStreaming(isStreaming)
12131218
}, [isStreaming, lastMessage, wasStreaming, isAutoApproved, messages.length])

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

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1493,3 +1493,175 @@ describe("ChatView - Message Queueing Tests", () => {
14931493
expect(input.getAttribute("data-sending-disabled")).toBe("false")
14941494
})
14951495
})
1496+
1497+
describe("ChatView - Cancel Button State Tests", () => {
1498+
beforeEach(() => {
1499+
vi.clearAllMocks()
1500+
vi.mocked(vscode.postMessage).mockClear()
1501+
})
1502+
1503+
it("resets didClickCancel state when streaming ends", async () => {
1504+
const { getByText, queryByText } = renderChatView()
1505+
1506+
// First hydrate state with initial task
1507+
mockPostMessage({
1508+
clineMessages: [
1509+
{
1510+
type: "say",
1511+
say: "task",
1512+
ts: Date.now() - 2000,
1513+
text: "Initial task",
1514+
},
1515+
],
1516+
})
1517+
1518+
// Add a streaming API request
1519+
mockPostMessage({
1520+
clineMessages: [
1521+
{
1522+
type: "say",
1523+
say: "task",
1524+
ts: Date.now() - 2000,
1525+
text: "Initial task",
1526+
},
1527+
{
1528+
type: "say",
1529+
say: "api_req_started",
1530+
ts: Date.now() - 1000,
1531+
text: JSON.stringify({ model: "openai-compatible" }),
1532+
partial: true,
1533+
},
1534+
],
1535+
})
1536+
1537+
// Wait for cancel button to appear
1538+
await waitFor(() => {
1539+
expect(getByText("chat:cancel.title")).toBeInTheDocument()
1540+
})
1541+
1542+
// Click cancel button
1543+
const cancelButton = getByText("chat:cancel.title")
1544+
act(() => {
1545+
cancelButton.click()
1546+
})
1547+
1548+
// Verify cancel task was sent
1549+
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "cancelTask" })
1550+
1551+
// Clear the mock to check for subsequent calls
1552+
vi.mocked(vscode.postMessage).mockClear()
1553+
1554+
// Simulate streaming ending by updating messages with completed API request
1555+
mockPostMessage({
1556+
clineMessages: [
1557+
{
1558+
type: "say",
1559+
say: "task",
1560+
ts: Date.now() - 2000,
1561+
text: "Initial task",
1562+
},
1563+
{
1564+
type: "say",
1565+
say: "api_req_started",
1566+
ts: Date.now() - 1000,
1567+
text: JSON.stringify({
1568+
model: "openai-compatible",
1569+
cost: 0.01, // Cost indicates request finished
1570+
cancelReason: "User cancelled",
1571+
}),
1572+
partial: false,
1573+
},
1574+
],
1575+
})
1576+
1577+
// Wait for UI to update
1578+
await waitFor(() => {
1579+
// Cancel button should no longer be visible when not streaming
1580+
expect(queryByText("chat:cancel.title")).not.toBeInTheDocument()
1581+
})
1582+
1583+
// Add a new streaming request to verify didClickCancel was reset
1584+
mockPostMessage({
1585+
clineMessages: [
1586+
{
1587+
type: "say",
1588+
say: "task",
1589+
ts: Date.now() - 2000,
1590+
text: "Initial task",
1591+
},
1592+
{
1593+
type: "say",
1594+
say: "api_req_started",
1595+
ts: Date.now() - 1000,
1596+
text: JSON.stringify({
1597+
model: "openai-compatible",
1598+
cost: 0.01,
1599+
cancelReason: "User cancelled",
1600+
}),
1601+
partial: false,
1602+
},
1603+
{
1604+
type: "say",
1605+
say: "api_req_started",
1606+
ts: Date.now(),
1607+
text: JSON.stringify({ model: "openai-compatible" }),
1608+
partial: true, // New streaming request
1609+
},
1610+
],
1611+
})
1612+
1613+
// Wait for cancel button to appear again
1614+
await waitFor(() => {
1615+
expect(getByText("chat:cancel.title")).toBeInTheDocument()
1616+
})
1617+
1618+
// The cancel button should be enabled
1619+
// This verifies that didClickCancel was properly reset
1620+
const newCancelButton = getByText("chat:cancel.title")
1621+
// The button should not be disabled
1622+
expect(newCancelButton).not.toBeDisabled()
1623+
})
1624+
1625+
it("maintains correct button opacity based on streaming and didClickCancel state", async () => {
1626+
const { container } = renderChatView()
1627+
1628+
// First hydrate state with initial task
1629+
mockPostMessage({
1630+
clineMessages: [
1631+
{
1632+
type: "say",
1633+
say: "task",
1634+
ts: Date.now() - 2000,
1635+
text: "Initial task",
1636+
},
1637+
],
1638+
})
1639+
1640+
// Add a streaming API request
1641+
mockPostMessage({
1642+
clineMessages: [
1643+
{
1644+
type: "say",
1645+
say: "task",
1646+
ts: Date.now() - 2000,
1647+
text: "Initial task",
1648+
},
1649+
{
1650+
type: "say",
1651+
say: "api_req_started",
1652+
ts: Date.now() - 1000,
1653+
text: JSON.stringify({ model: "openai-compatible" }),
1654+
partial: true,
1655+
},
1656+
],
1657+
})
1658+
1659+
// Wait for button container to appear
1660+
await waitFor(() => {
1661+
const buttonContainer = container.querySelector('[class*="opacity-"]')
1662+
expect(buttonContainer).toBeInTheDocument()
1663+
// Should have opacity-100 when streaming and cancel not clicked
1664+
expect(buttonContainer?.className).toContain("opacity-100")
1665+
})
1666+
})
1667+
})

0 commit comments

Comments
 (0)