{
// Use parent's setInputValue if available
if (props.setInputValue) {
props.setInputValue(e.target.value)
}
}}
onKeyDown={(e) => {
// Only call onSend when Enter is pressed (simulating real behavior)
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
props.onSend()
}
}}
data-sending-disabled={props.sendingDisabled}
/>
)
})
return {
default: ChatTextAreaComponent,
ChatTextArea: ChatTextAreaComponent, // Export as named export too
}
})
// Mock VSCode components
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeButton: function MockVSCodeButton({
children,
onClick,
appearance,
}: {
children: React.ReactNode
onClick?: () => void
appearance?: string
}) {
return (
)
},
VSCodeTextField: function MockVSCodeTextField({
value,
onInput,
placeholder,
}: {
value?: string
onInput?: (e: { target: { value: string } }) => void
placeholder?: string
}) {
return (
onInput?.({ target: { value: e.target.value } })}
placeholder={placeholder}
/>
)
},
VSCodeLink: function MockVSCodeLink({ children, href }: { children: React.ReactNode; href?: string }) {
return {children}
},
}))
// Mock window.postMessage to trigger state hydration
const mockPostMessage = (state: Partial) => {
window.postMessage(
{
type: "state",
state: {
version: "1.0.0",
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
alwaysAllowExecute: false,
cloudIsAuthenticated: false,
telemetrySetting: "enabled",
...state,
},
},
"*",
)
}
const defaultProps: ChatViewProps = {
isHidden: false,
showAnnouncement: false,
hideAnnouncement: () => {},
}
const queryClient = new QueryClient()
const renderChatView = (props: Partial = {}) => {
return render(
,
)
}
describe("ChatView - Sound Playing Tests", () => {
beforeEach(() => vi.clearAllMocks())
it("plays celebration sound for completion results", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
soundEnabled: true, // Enable sound
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Clear any initial calls
mockPlayFunction.mockClear()
// Add completion result
mockPostMessage({
soundEnabled: true, // Enable sound
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "completion_result",
ts: Date.now(),
text: "Task completed successfully",
partial: false, // Ensure it's not partial
},
],
})
// Wait for sound to be played
await waitFor(() => {
expect(mockPlayFunction).toHaveBeenCalled()
})
})
it("plays progress_loop sound for api failures", async () => {
renderChatView()
// First hydrate state with initial task
mockPostMessage({
soundEnabled: true, // Enable sound
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Clear any initial calls
mockPlayFunction.mockClear()
// Add API failure
mockPostMessage({
soundEnabled: true, // Enable sound
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "api_req_failed",
ts: Date.now(),
text: "API request failed",
partial: false, // Ensure it's not partial
},
],
})
// Wait for sound to be played
await waitFor(() => {
expect(mockPlayFunction).toHaveBeenCalled()
})
})
it("does not play sound when resuming a task from history", () => {
renderChatView()
// Clear any initial calls
mockPlayFunction.mockClear()
// Hydrate state with a task that has a resumeTaskId (indicating it's resumed from history)
mockPostMessage({
resumeTaskId: "task-123",
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Resumed task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
},
],
})
// Should not play sound when resuming from history
expect(mockPlayFunction).not.toHaveBeenCalled()
})
it("does not play sound when resuming a completed task from history", () => {
renderChatView()
// Clear any initial calls
mockPlayFunction.mockClear()
// Hydrate state with a completed task that has a resumeTaskId
mockPostMessage({
resumeTaskId: "task-123",
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Resumed task",
},
{
type: "ask",
ask: "completion_result",
ts: Date.now(),
text: "Task completed",
},
],
})
// Should not play sound for completion when resuming from history
expect(mockPlayFunction).not.toHaveBeenCalled()
})
})
describe("ChatView - Focus Grabbing Tests", () => {
beforeEach(() => vi.clearAllMocks())
it("does not grab focus when follow-up question presented", async () => {
const { getByTestId } = renderChatView()
// First hydrate state with initial task
mockPostMessage({
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Wait for the component to fully render and settle before clearing mocks
await waitFor(() => {
expect(getByTestId("chat-textarea")).toBeInTheDocument()
})
// Wait for the debounced focus effect to fire (50ms debounce + buffer for CI variability)
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
})
// Clear any initial calls after state has settled
mockFocus.mockClear()
// Add follow-up question
mockPostMessage({
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "followup",
ts: Date.now(),
text: "Should I continue?",
},
],
})
// Wait for state update to complete
await waitFor(() => {
expect(getByTestId("chat-textarea")).toBeInTheDocument()
})
// Should not grab focus for follow-up questions
expect(mockFocus).not.toHaveBeenCalled()
})
})
describe("ChatView - Version Indicator Tests", () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset the mock to return null by default
mockVersionIndicator.mockReturnValue(null)
})
it("displays version indicator button", () => {
// Mock VersionIndicator to return a button
mockVersionIndicator.mockReturnValue(
React.createElement("button", {
"data-testid": "version-indicator",
"aria-label": "Version 1.0.0",
className: "version-indicator-button",
}),
)
const { getByTestId } = renderChatView()
// Hydrate state with no active task
mockPostMessage({
version: "1.0.0",
clineMessages: [],
})
// Should display version indicator
expect(getByTestId("version-indicator")).toBeInTheDocument()
})
it("opens announcement modal when version indicator is clicked", async () => {
// Mock VersionIndicator to return a button with onClick
mockVersionIndicator.mockImplementation(({ onClick }: { onClick?: () => void }) =>
React.createElement("button", {
"data-testid": "version-indicator",
onClick,
}),
)
const { getByTestId, queryByTestId } = renderChatView({ showAnnouncement: false })
// Hydrate state
mockPostMessage({
version: "1.0.0",
clineMessages: [],
})
// Wait for component to render
await waitFor(() => {
expect(getByTestId("version-indicator")).toBeInTheDocument()
})
// Click version indicator
const versionIndicator = getByTestId("version-indicator")
act(() => {
versionIndicator.click()
})
// Wait for announcement modal to appear
await waitFor(() => {
expect(queryByTestId("announcement-modal")).toBeInTheDocument()
})
})
it("version indicator has correct styling classes", () => {
// Mock VersionIndicator to return a button with specific classes
mockVersionIndicator.mockReturnValue(
React.createElement("button", {
"data-testid": "version-indicator",
className: "version-indicator-button absolute top-2 right-2",
}),
)
const { getByTestId } = renderChatView()
// Hydrate state
mockPostMessage({
version: "1.0.0",
clineMessages: [],
})
const versionIndicator = getByTestId("version-indicator")
expect(versionIndicator.className).toContain("version-indicator-button")
expect(versionIndicator.className).toContain("absolute")
expect(versionIndicator.className).toContain("top-2")
expect(versionIndicator.className).toContain("right-2")
})
it("version indicator has proper accessibility attributes", () => {
// Mock VersionIndicator to return a button with aria-label
mockVersionIndicator.mockReturnValue(
React.createElement("button", {
"data-testid": "version-indicator",
"aria-label": "Version 1.0.0",
role: "button",
}),
)
const { getByTestId } = renderChatView()
// Hydrate state
mockPostMessage({
version: "1.0.0",
clineMessages: [],
})
const versionIndicator = getByTestId("version-indicator")
expect(versionIndicator.getAttribute("aria-label")).toBe("Version 1.0.0")
expect(versionIndicator.getAttribute("role")).toBe("button")
})
it("does not display version indicator when there is an active task", () => {
// Mock VersionIndicator to return null (simulating hidden state)
mockVersionIndicator.mockReturnValue(null)
const { queryByTestId } = renderChatView()
// Hydrate state with active task
mockPostMessage({
version: "1.0.0",
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now(),
text: "Active task",
},
],
})
// Should not display version indicator during active task
expect(queryByTestId("version-indicator")).not.toBeInTheDocument()
})
it("displays version indicator only on welcome screen (no task)", () => {
// Mock VersionIndicator to return a button
mockVersionIndicator.mockReturnValue(React.createElement("button", { "data-testid": "version-indicator" }))
const { queryByTestId } = renderChatView()
// Hydrate state with no active task
mockPostMessage({
version: "1.0.0",
clineMessages: [],
})
// Should display version indicator on welcome screen
expect(queryByTestId("version-indicator")).toBeInTheDocument()
})
})
describe("ChatView - DismissibleUpsell Display Tests", () => {
beforeEach(() => vi.clearAllMocks())
it("does not show DismissibleUpsell when user is authenticated to Cloud", () => {
const { queryByTestId } = renderChatView()
// Hydrate state with user authenticated to cloud
mockPostMessage({
cloudIsAuthenticated: true,
taskHistory: [
{ id: "1", ts: Date.now() - 3000 },
{ id: "2", ts: Date.now() - 2000 },
{ id: "3", ts: Date.now() - 1000 },
{ id: "4", ts: Date.now() },
],
clineMessages: [], // No active task
})
// Should not show DismissibleUpsell when authenticated
expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument()
})
it("does not show DismissibleUpsell when user has only run 3 tasks in their history", () => {
const { queryByTestId } = renderChatView()
// Hydrate state with user not authenticated but only 3 tasks
mockPostMessage({
cloudIsAuthenticated: false,
taskHistory: [
{ id: "1", ts: Date.now() - 2000 },
{ id: "2", ts: Date.now() - 1000 },
{ id: "3", ts: Date.now() },
],
clineMessages: [], // No active task
})
// Should not show DismissibleUpsell with less than 4 tasks
expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument()
})
it("shows DismissibleUpsell when user is not authenticated and has run 6 or more tasks", async () => {
const { getByTestId } = renderChatView()
// Hydrate state with user not authenticated and 4 tasks
mockPostMessage({
cloudIsAuthenticated: false,
taskHistory: [
{ id: "1", ts: Date.now() - 6000 },
{ id: "2", ts: Date.now() - 5000 },
{ id: "3", ts: Date.now() - 4000 },
{ id: "4", ts: Date.now() - 3000 },
{ id: "5", ts: Date.now() - 2000 },
{ id: "6", ts: Date.now() - 1000 },
{ id: "7", ts: Date.now() },
],
clineMessages: [], // No active task
})
// Wait for component to render and show DismissibleUpsell
await waitFor(() => {
expect(getByTestId("dismissible-upsell")).toBeInTheDocument()
})
})
it("does not show DismissibleUpsell when there is an active task (regardless of auth status)", async () => {
const { queryByTestId } = renderChatView()
// Hydrate state with active task
mockPostMessage({
cloudIsAuthenticated: false,
taskHistory: [
{ id: "1", ts: Date.now() - 3000 },
{ id: "2", ts: Date.now() - 2000 },
{ id: "3", ts: Date.now() - 1000 },
{ id: "4", ts: Date.now() },
],
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now(),
text: "Active task",
},
],
})
// Wait for component to render with active task
await waitFor(() => {
// Should not show DismissibleUpsell during active task
expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument()
// Should not show RooTips either since the entire welcome screen is hidden during active tasks
expect(queryByTestId("roo-tips")).not.toBeInTheDocument()
// Should not show RooHero either since the entire welcome screen is hidden during active tasks
expect(queryByTestId("roo-hero")).not.toBeInTheDocument()
})
})
it("shows RooTips when user is authenticated (instead of DismissibleUpsell)", () => {
const { queryByTestId, getByTestId } = renderChatView()
// Hydrate state with user authenticated to cloud
mockPostMessage({
cloudIsAuthenticated: true,
taskHistory: [
{ id: "1", ts: Date.now() - 3000 },
{ id: "2", ts: Date.now() - 2000 },
{ id: "3", ts: Date.now() - 1000 },
{ id: "4", ts: Date.now() },
],
clineMessages: [], // No active task
})
// Should not show DismissibleUpsell but should show RooTips
expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument()
expect(getByTestId("roo-tips")).toBeInTheDocument()
})
it("shows RooTips when user has fewer than 6 tasks (instead of DismissibleUpsell)", () => {
const { queryByTestId, getByTestId } = renderChatView()
// Hydrate state with user not authenticated but fewer than 4 tasks
mockPostMessage({
cloudIsAuthenticated: false,
taskHistory: [
{ id: "1", ts: Date.now() - 2000 },
{ id: "2", ts: Date.now() - 1000 },
{ id: "3", ts: Date.now() },
],
clineMessages: [], // No active task
})
// Should not show DismissibleUpsell but should show RooTips
expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument()
expect(getByTestId("roo-tips")).toBeInTheDocument()
})
})
describe("ChatView - Message Queueing Tests", () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset the mock to clear any initial calls
vi.mocked(vscode.postMessage).mockClear()
})
it("shows sending is disabled when task is active", async () => {
const { getByTestId } = renderChatView()
// Hydrate state with active task that should disable sending
mockPostMessage({
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 1000,
text: "Task in progress",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
partial: true, // Partial messages disable sending
},
],
})
// Wait for state to be updated and check that sending is disabled
await waitFor(() => {
const chatTextArea = getByTestId("chat-textarea")
const input = chatTextArea.querySelector("input")!
expect(input.getAttribute("data-sending-disabled")).toBe("true")
})
})
it("shows sending is enabled when no task is active", async () => {
const { getByTestId } = renderChatView()
// Hydrate state with completed task
mockPostMessage({
clineMessages: [
{
type: "ask",
ask: "completion_result",
ts: Date.now(),
text: "Task completed",
partial: false,
},
],
})
// Wait for state to be updated
await waitFor(() => {
expect(getByTestId("chat-textarea")).toBeInTheDocument()
})
// Check that sending is enabled
const chatTextArea = getByTestId("chat-textarea")
const input = chatTextArea.querySelector("input")!
expect(input.getAttribute("data-sending-disabled")).toBe("false")
})
it("queues messages when API request is in progress (spinner visible)", async () => {
const { getByTestId } = renderChatView()
// First hydrate state with initial task
mockPostMessage({
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Clear any initial calls
vi.mocked(vscode.postMessage).mockClear()
// Add api_req_started without cost (spinner state - API request in progress)
mockPostMessage({
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "say",
say: "api_req_started",
ts: Date.now(),
text: JSON.stringify({ apiProtocol: "anthropic" }), // No cost = still streaming
},
],
})
// Wait for state to be updated
await waitFor(() => {
expect(getByTestId("chat-textarea")).toBeInTheDocument()
})
// Clear message calls before simulating user input
vi.mocked(vscode.postMessage).mockClear()
// Simulate user typing and sending a message during the spinner
const chatTextArea = getByTestId("chat-textarea")
const input = chatTextArea.querySelector("input")! as HTMLInputElement
// Trigger message send by simulating typing and Enter key press
await act(async () => {
// Use fireEvent to properly trigger React's onChange handler
fireEvent.change(input, { target: { value: "follow-up question during spinner" } })
// Simulate pressing Enter to send
fireEvent.keyDown(input, { key: "Enter", code: "Enter" })
})
// Verify that the message was queued, not sent as askResponse
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "queueMessage",
text: "follow-up question during spinner",
images: [],
})
})
// Verify it was NOT sent as a direct askResponse (which would get lost)
expect(vscode.postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({
type: "askResponse",
askResponse: "messageResponse",
}),
)
})
it("sends messages normally when API request is complete (cost present)", async () => {
const { getByTestId } = renderChatView()
// Hydrate state with completed API request (cost present)
mockPostMessage({
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "say",
say: "api_req_started",
ts: Date.now(),
text: JSON.stringify({
apiProtocol: "anthropic",
cost: 0.05, // Cost present = streaming complete
tokensIn: 100,
tokensOut: 50,
}),
},
{
type: "say",
say: "text",
ts: Date.now(),
text: "Response from API",
},
],
})
// Wait for state to be updated
await waitFor(() => {
expect(getByTestId("chat-textarea")).toBeInTheDocument()
})
// Clear message calls before simulating user input
vi.mocked(vscode.postMessage).mockClear()
// Simulate user sending a message when API is done
const chatTextArea = getByTestId("chat-textarea")
const input = chatTextArea.querySelector("input")! as HTMLInputElement
await act(async () => {
// Use fireEvent to properly trigger React's onChange handler
fireEvent.change(input, { target: { value: "follow-up after completion" } })
// Simulate pressing Enter to send
fireEvent.keyDown(input, { key: "Enter", code: "Enter" })
})
// Verify that the message was sent as askResponse, not queued
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "messageResponse",
text: "follow-up after completion",
images: [],
})
})
// Verify it was NOT queued
expect(vscode.postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({
type: "queueMessage",
}),
)
})
it("preserves message order when messages sent during queue drain", async () => {
const { getByTestId } = renderChatView()
// Hydrate state with API request in progress and existing queue
mockPostMessage({
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "say",
say: "api_req_started",
ts: Date.now(),
text: JSON.stringify({ apiProtocol: "anthropic" }), // No cost = still streaming
},
],
messageQueue: [
{ id: "msg1", text: "queued message 1", images: [] },
{ id: "msg2", text: "queued message 2", images: [] },
],
})
// Wait for state to be updated
await waitFor(() => {
expect(getByTestId("chat-textarea")).toBeInTheDocument()
})
// Clear message calls before simulating user input
vi.mocked(vscode.postMessage).mockClear()
// Simulate user sending a new message while queue has items
const chatTextArea = getByTestId("chat-textarea")
const input = chatTextArea.querySelector("input")! as HTMLInputElement
await act(async () => {
fireEvent.change(input, { target: { value: "message during queue drain" } })
fireEvent.keyDown(input, { key: "Enter", code: "Enter" })
})
// Verify that the new message was queued (not sent directly) to preserve order
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "queueMessage",
text: "message during queue drain",
images: [],
})
})
// Verify it was NOT sent as askResponse (which would break ordering)
expect(vscode.postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({
type: "askResponse",
askResponse: "messageResponse",
}),
)
})
})
describe("ChatView - Context Condensing Indicator Tests", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("should add a condensing message to groupedMessages when isCondensing is true", async () => {
// This test verifies that when the condenseTaskContextStarted message is received,
// the isCondensing state is set to true and a synthetic condensing message is added
// to the grouped messages list
const { getByTestId, container } = renderChatView()
// First hydrate state with an active task
mockPostMessage({
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "say",
say: "api_req_started",
ts: Date.now() - 1000,
text: JSON.stringify({ apiProtocol: "anthropic" }),
},
],
})
// Wait for component to render
await waitFor(() => {
expect(getByTestId("chat-view")).toBeInTheDocument()
})
// Allow time for useEvent hook to register message listener
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10))
})
// Dispatch a MessageEvent directly to trigger the message handler
// This simulates the VSCode extension sending a message to the webview
await act(async () => {
const event = new MessageEvent("message", {
data: {
type: "condenseTaskContextStarted",
text: "test-task-id",
},
})
window.dispatchEvent(event)
// Wait for React state updates
await new Promise((resolve) => setTimeout(resolve, 0))
})
// Check that groupedMessages now includes a condensing message
// With Virtuoso mocked, items render directly and we can find the ChatRow with partial condense_context message
await waitFor(
() => {
const rows = container.querySelectorAll('[data-testid="chat-row"]')
// Check for the actual message structure: partial condense_context message
const condensingRow = Array.from(rows).find((row) => {
const text = row.textContent || ""
return text.includes('"say":"condense_context"') && text.includes('"partial":true')
})
expect(condensingRow).toBeTruthy()
},
{ timeout: 2000 },
)
})
})