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
63 changes: 58 additions & 5 deletions frontend/src/scenes/max/components/QuestionInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import '@testing-library/jest-dom'

import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { BindLogic, Provider } from 'kea'
import type { ComponentProps } from 'react'

import { useMocks } from '~/mocks/jest'
import { initKeaTests } from '~/test/init'

import { maxGlobalLogic } from '../maxGlobalLogic'
import { maxLogic } from '../maxLogic'
import { maxThreadLogic } from '../maxThreadLogic'
import { maxMocks } from '../testUtils'
import { maxMocks, mockStream } from '../testUtils'
import { QuestionInput } from './QuestionInput'

jest.mock(
Expand All @@ -24,32 +25,36 @@ jest.mock(
describe('QuestionInput', () => {
let maxLogicInstance: ReturnType<typeof maxLogic.build>
let threadLogicInstance: ReturnType<typeof maxThreadLogic.build>
let maxGlobalLogicInstance: ReturnType<typeof maxGlobalLogic.build>
let threadProps: { panelId: string; conversationId: string }

beforeEach(() => {
useMocks(maxMocks)
initKeaTests()

const maxGlobalLogicInstance = maxGlobalLogic()
maxGlobalLogicInstance = maxGlobalLogic()
maxGlobalLogicInstance.mount()
jest.spyOn(maxGlobalLogicInstance.selectors, 'dataProcessingAccepted').mockReturnValue(true)

maxLogicInstance = maxLogic({ panelId: 'test' })
maxLogicInstance.mount()

const threadProps = { panelId: 'test', conversationId: maxLogicInstance.values.frontendConversationId }
threadProps = { panelId: 'test', conversationId: maxLogicInstance.values.frontendConversationId }
threadLogicInstance = maxThreadLogic(threadProps)
threadLogicInstance.mount()
})

function renderQuestionInput(props: Partial<ComponentProps<typeof QuestionInput>> = {}): void {
render(
<Provider>
<BindLogic logic={maxLogic} props={{ panelId: 'test' }}>
<BindLogic logic={maxThreadLogic} props={threadProps}>
<QuestionInput />
<QuestionInput {...props} />
</BindLogic>
</BindLogic>
</Provider>
)
})
}

afterEach(() => {
cleanup()
Expand All @@ -64,6 +69,7 @@ describe('QuestionInput', () => {
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0))

it('does not release a sandbox pre-warm when blur moves to the send button', async () => {
renderQuestionInput()
// Simulate a completed warm; a release would clear the flag (and relay-cancel the warm Run).
threadLogicInstance.cache.prewarmed = true
threadLogicInstance.cache.prewarming = false
Expand All @@ -81,6 +87,7 @@ describe('QuestionInput', () => {
})

it('releases a sandbox pre-warm when blur leaves the input for somewhere else', async () => {
renderQuestionInput()
threadLogicInstance.cache.prewarmed = true
threadLogicInstance.cache.prewarming = false

Expand All @@ -94,6 +101,7 @@ describe('QuestionInput', () => {
})

it('reopens the popover after Escape dismisses it and a fresh slash is typed', async () => {
renderQuestionInput()
const input = screen.getByRole('textbox') as HTMLTextAreaElement

fireEvent.change(input, { target: { value: '/' } })
Expand All @@ -109,17 +117,61 @@ describe('QuestionInput', () => {
await waitFor(() => expect(slashCommandItem()).toBeInTheDocument())
})

it('keeps long content in the scrolling textarea while the send control stays mounted', async () => {
renderQuestionInput()
const input = screen.getByRole('textbox') as HTMLTextAreaElement
const longPrompt = Array.from({ length: 40 }, (_, index) => `Line ${index + 1}: explain this`).join('\n')

fireEvent.change(input, { target: { value: longPrompt } })

await waitFor(() => expect(input).toHaveValue(longPrompt))
expect(input).toHaveClass('max-h-[min(35dvh,13.5rem)]')
expect(input).toHaveClass('overflow-y-auto')
expect(document.querySelector('[data-attr="max-send-message"]')).toBeInTheDocument()
})

it('submits with Enter and preserves Shift+Enter for new lines', async () => {
mockStream()
const onSubmit = jest.fn()
renderQuestionInput({ onSubmit })
const input = screen.getByRole('textbox') as HTMLTextAreaElement

fireEvent.change(input, { target: { value: 'Explain activation trends' } })
await waitFor(() => expect(input).toHaveValue('Explain activation trends'))

fireEvent.keyDown(input, { key: 'Enter', shiftKey: true })
expect(onSubmit).not.toHaveBeenCalled()

fireEvent.keyDown(input, { key: 'Enter' })
expect(onSubmit).toHaveBeenCalledTimes(1)
})

it('disables empty and loading composer states', async () => {
renderQuestionInput()
expect(document.querySelector('[data-attr="max-send-message"]')).toHaveAttribute('aria-disabled', 'true')

cleanup()
jest.spyOn(maxGlobalLogicInstance.selectors, 'dataProcessingAccepted').mockReturnValue(false)
threadLogicInstance.actions.reconnectToStream()
renderQuestionInput()

expect(threadLogicInstance.values.threadLoading).toBe(true)
await waitFor(() => expect(screen.getByRole('textbox')).toBeDisabled())
})

describe('stop button cancel state', () => {
const sendButton = (): HTMLElement | null => document.querySelector('[data-attr="max-send-message"]')
const stopButton = (): HTMLElement | null => document.querySelector('[data-attr="max-stop-generation"]')

it('shows the stop affordance while streaming and not cancelling', async () => {
renderQuestionInput()
threadLogicInstance.actions.reconnectToStream()
await waitFor(() => expect(stopButton()).not.toBeNull())
expect(sendButton()).toHaveAttribute('aria-disabled', 'true')
})

it('shows send (not stop) while cancelLoading is true', async () => {
renderQuestionInput()
threadLogicInstance.actions.reconnectToStream()
threadLogicInstance.actions.setCancelLoading(true)

Expand All @@ -130,6 +182,7 @@ describe('QuestionInput', () => {
})

it('returns to send (not stop) after cancel resolves and loading clears', async () => {
renderQuestionInput()
threadLogicInstance.actions.reconnectToStream()
threadLogicInstance.actions.setCancelLoading(true)
await waitFor(() => expect(sendButton()).not.toBeNull())
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/scenes/max/components/QuestionInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,15 +298,15 @@ export const QuestionInput = React.forwardRef<HTMLDivElement, QuestionInputProps
)}
>
{/* Have to increase z-index to overlay ToolsDisplay */}
<div className="relative w-full flex flex-col z-1">
<div className="relative w-full flex flex-col z-1 min-h-0 max-h-[calc(100dvh-var(--scene-layout-header-height)-1rem)]">
{children}
{agentMode === AgentMode.Research && threadMessageCount === 0 && (
<div className="flex justify-center items-center gap-1 w-full px-2 py-1.5 mb-2 bg-warning/10 text-primary text-xs rounded-lg border-primary">
Research mode is a free beta feature with lower daily limits
</div>
)}
{queueingEnabled && (queuedMessages.length > 0 || queueSubmitting) && (
<div className="px-3 py-2">
<div className="min-h-0 overflow-y-auto px-3 py-2">
<div className="text-xs text-muted mb-1.5 flex items-center gap-1.5">
Up next
{queueSubmitting && <Spinner size="small" />}
Expand Down Expand Up @@ -335,7 +335,7 @@ export const QuestionInput = React.forwardRef<HTMLDivElement, QuestionInputProps
<label
htmlFor="question-input"
className={cn(
'input-like flex flex-col cursor-text',
'input-like flex flex-col cursor-text shrink-0',
'border border-primary',
'bg-[var(--color-bg-fill-input)]',
isThreadVisible ? 'border-primary m-0.5 rounded-[7px]' : 'rounded-lg',
Expand Down Expand Up @@ -447,7 +447,7 @@ export const QuestionInput = React.forwardRef<HTMLDivElement, QuestionInputProps
minRows={1}
maxRows={10}
className={cn(
'!border-none !bg-transparent min-h-16 py-2 pl-2 resize-none',
'!border-none !bg-transparent min-h-16 max-h-[min(35dvh,13.5rem)] overflow-y-auto py-2 pl-2 resize-none',
handsFreeFlagEnabled ? 'pr-20' : 'pr-12',
// Hide the native caret so only the enlarged fill-in caret shows.
showFillInHint && 'caret-transparent'
Expand Down
Loading