Skip to content
Merged
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
24 changes: 23 additions & 1 deletion frontend/src/components/message/MessagePart.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import { MessagePart } from './MessagePart'
import type { MessagePart as MessagePartType } from '@/api/types'

const mocks = vi.hoisted(() => ({
useTTS: vi.fn(),
useSettings: vi.fn(),
usePermissions: vi.fn(),
useQuestions: vi.fn(),
}))

vi.mock('@/hooks/useTTS', () => ({
Expand All @@ -16,6 +20,11 @@ vi.mock('@/hooks/useSettings', () => ({
useSettings: mocks.useSettings,
}))

vi.mock('@/contexts/EventContext', () => ({
usePermissions: () => mocks.usePermissions(),
useQuestions: () => mocks.useQuestions(),
}))

interface MockTTSReturn {
speakMessage: ReturnType<typeof vi.fn>
stop: ReturnType<typeof vi.fn>
Expand Down Expand Up @@ -61,6 +70,12 @@ describe('MessagePart', () => {
updateSettings: vi.fn(),
isUpdating: false,
})
mocks.usePermissions.mockReturnValue({
getForCallID: vi.fn(() => null),
})
mocks.useQuestions.mockReturnValue({
getForCallID: vi.fn(() => null),
})
})

const setup = (options: {
Expand Down Expand Up @@ -440,7 +455,14 @@ describe('MessagePart', () => {
})

const part = createToolPart()
const { container } = render(<MessagePart part={part} />)
const queryClient = new QueryClient()
const { container } = render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<MessagePart part={part} />
</MemoryRouter>
</QueryClientProvider>
)

expect(container.firstChild).not.toBeNull()
})
Expand Down
18 changes: 10 additions & 8 deletions frontend/src/components/message/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ interface PromptInputProps {
repoId?: number
disabled?: boolean
showScrollButton?: boolean
hasActiveStream?: boolean
isSessionActive?: boolean
isStreamingResponse?: boolean
onScrollToBottom?: () => void
onShowSessionsDialog?: () => void
onShowModelsDialog?: () => void
Expand All @@ -79,7 +80,8 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
repoId,
disabled,
showScrollButton,
hasActiveStream = false,
isSessionActive = false,
isStreamingResponse = false,
onScrollToBottom,
onShowSessionsDialog,
onShowModelsDialog,
Expand Down Expand Up @@ -222,7 +224,7 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
pendingVoiceAutoSubmitRef.current = false
setIsVoiceAutoSendPending(false)

if (hasActiveStream) {
if (isStreamingResponse) {
const parts = parsePromptToParts(prompt, attachedFiles, imageAttachments)
const agentUsed = selectedAgent || currentMode
sendPrompt.mutate({
Expand Down Expand Up @@ -925,8 +927,8 @@ const { model, modelString, setModel: setStoredModel } = useModelSelection(opcod
const { setShowDialog, hasForSession: hasPermissionsForSession } = usePermissions()
const hasPendingPermissionForSession = hasPermissionsForSession(sessionID)
const { hasVariants, currentVariant, cycleVariant } = useVariants(opcodeUrl, directory)
const showStopButton = hasActiveStream
const hideSecondaryButtons = isMobile && hasActiveStream
const showStopButton = isSessionActive
const hideSecondaryButtons = isMobile && isSessionActive
const showVoiceFeedback = isVoiceHoldActive || isRecording || isTogglingRecording || isProcessing || isVoiceAutoSendPending
const voiceFeedbackLabel = isProcessing
? isVoiceAutoSendPending
Expand Down Expand Up @@ -1063,7 +1065,7 @@ return (
isBashMode={isBashMode}
disabled={disabled}
/>
{hasActiveStream ? (
{isSessionActive ? (
<div className="px-2.5 py-1.5 md:px-3 md:py-2 rounded-lg text-xs md:text-sm font-medium text-muted-foreground max-w-[120px] md:max-w-[180px]">
<SessionStatusIndicator sessionID={sessionID} showLabel />
</div>
Expand Down Expand Up @@ -1198,9 +1200,9 @@ return (
? 'bg-orange-500 hover:bg-orange-600 border-orange-400 text-primary-foreground ring-orange-500/20'
: 'bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed text-primary-foreground border-white/30'
}`}
title={hasPendingPermissionForSession ? 'View pending permission' : (hasActiveStream ? 'Queue message' : 'Send')}
title={hasPendingPermissionForSession ? 'View pending permission' : (isStreamingResponse ? 'Queue message' : 'Send')}
>
<span className="whitespace-nowrap">{hasPendingPermissionForSession ? 'View' : (hasActiveStream ? 'Queue' : 'Send')}</span>
<span className="whitespace-nowrap">{hasPendingPermissionForSession ? 'View' : (isStreamingResponse ? 'Queue' : 'Send')}</span>
</button>
</div>
</div>
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/components/ui/bottom-sheet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,19 @@ describe('BottomSheet', () => {
</BottomSheet>,
)
const dialog = document.querySelector('[role="dialog"]')
expect(dialog).toHaveClass('pb-safe')
expect(dialog).toBeInTheDocument()
})

it('applies pb-safe class to BottomSheetContent', () => {
render(
<BottomSheet isOpen onClose={() => {}} ariaLabel="Test sheet">
<BottomSheetContent>
<div>Test content</div>
</BottomSheetContent>
</BottomSheet>,
)
const content = screen.getByText('Test content').parentElement
expect(content).toHaveClass('pb-safe')
})

it('applies custom heightClass', () => {
Expand Down
30 changes: 15 additions & 15 deletions frontend/src/hooks/__tests__/useAutoPlayLastResponse.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ describe('useAutoPlayLastResponse', () => {
sessionId: 'test-session',
lastAssistantMessage: message,
lastAssistantText: 'Test text',
hasActiveStream: false,
isStreamingResponse: false,
})
)

Expand All @@ -142,7 +142,7 @@ describe('useAutoPlayLastResponse', () => {
sessionId: 'test-session',
lastAssistantMessage: message,
lastAssistantText: 'Test text',
hasActiveStream: false,
isStreamingResponse: false,
})
)

Expand All @@ -158,7 +158,7 @@ describe('useAutoPlayLastResponse', () => {
sessionId: 'test-session',
lastAssistantMessage: message,
lastAssistantText: 'Test text',
hasActiveStream: false,
isStreamingResponse: false,
})
)

Expand All @@ -176,7 +176,7 @@ describe('useAutoPlayLastResponse', () => {
sessionId: 'test-session',
lastAssistantMessage: incompleteMessage,
lastAssistantText: 'Test text',
hasActiveStream: false,
isStreamingResponse: false,
},
}
)
Expand All @@ -188,7 +188,7 @@ describe('useAutoPlayLastResponse', () => {
sessionId: 'test-session',
lastAssistantMessage: completedMessage,
lastAssistantText: 'Test text',
hasActiveStream: false,
isStreamingResponse: false,
})

expect(mockSpeakMessage).toHaveBeenCalledTimes(1)
Expand All @@ -206,7 +206,7 @@ describe('useAutoPlayLastResponse', () => {
sessionId: 'test-session',
lastAssistantMessage: message,
lastAssistantText: 'Test text',
hasActiveStream: false,
isStreamingResponse: false,
},
}
)
Expand All @@ -217,7 +217,7 @@ describe('useAutoPlayLastResponse', () => {
sessionId: 'test-session',
lastAssistantMessage: message,
lastAssistantText: 'Test text',
hasActiveStream: false,
isStreamingResponse: false,
})

expect(mockSpeakMessage).not.toHaveBeenCalled()
Expand All @@ -235,7 +235,7 @@ describe('useAutoPlayLastResponse', () => {
sessionId: 'test-session',
lastAssistantMessage: firstMessage,
lastAssistantText: 'First text',
hasActiveStream: false,
isStreamingResponse: false,
},
}
)
Expand All @@ -247,7 +247,7 @@ describe('useAutoPlayLastResponse', () => {
sessionId: 'test-session',
lastAssistantMessage: secondMessage,
lastAssistantText: 'Second text',
hasActiveStream: false,
isStreamingResponse: false,
})

expect(mockSpeakMessage).toHaveBeenCalledTimes(1)
Expand All @@ -266,7 +266,7 @@ describe('useAutoPlayLastResponse', () => {
sessionId: 'session-1',
lastAssistantMessage: message1,
lastAssistantText: 'Session 1 text',
hasActiveStream: false,
isStreamingResponse: false,
},
}
)
Expand All @@ -278,14 +278,14 @@ describe('useAutoPlayLastResponse', () => {
sessionId: 'session-2',
lastAssistantMessage: message2,
lastAssistantText: 'Session 2 text',
hasActiveStream: false,
isStreamingResponse: false,
})

expect(mockSpeakMessage).toHaveBeenCalledTimes(1)
expect(mockSpeakMessage).toHaveBeenCalledWith('1', 'Session 2 text')
})

it('does NOT call speakMessage when hasActiveStream is true', () => {
it('does NOT call speakMessage when isStreamingResponse is true', () => {
setup()

const message = createMessage('1', Date.now())
Expand All @@ -294,7 +294,7 @@ describe('useAutoPlayLastResponse', () => {
sessionId: 'test-session',
lastAssistantMessage: message,
lastAssistantText: 'Test text',
hasActiveStream: true,
isStreamingResponse: true,
})
)

Expand All @@ -310,7 +310,7 @@ describe('useAutoPlayLastResponse', () => {
sessionId: 'test-session',
lastAssistantMessage: message,
lastAssistantText: '',
hasActiveStream: false,
isStreamingResponse: false,
})
)

Expand All @@ -326,7 +326,7 @@ describe('useAutoPlayLastResponse', () => {
sessionId: 'test-session',
lastAssistantMessage: message,
lastAssistantText: ' ',
hasActiveStream: false,
isStreamingResponse: false,
})
)

Expand Down
8 changes: 4 additions & 4 deletions frontend/src/hooks/useAutoPlayLastResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ interface UseAutoPlayLastResponseParams {
sessionId: string
lastAssistantMessage: MessageWithParts | undefined
lastAssistantText: string
hasActiveStream: boolean
isStreamingResponse: boolean
}

function isMessageCompleted(message: MessageWithParts['info']): boolean {
Expand All @@ -18,7 +18,7 @@ export function useAutoPlayLastResponse({
sessionId,
lastAssistantMessage,
lastAssistantText,
hasActiveStream,
isStreamingResponse,
}: UseAutoPlayLastResponseParams): void {
const { speakMessage, isEnabled: ttsEnabled } = useTTS()
const { preferences } = useSettings()
Expand Down Expand Up @@ -52,7 +52,7 @@ export function useAutoPlayLastResponse({
return
}

if (hasActiveStream || !lastAssistantMessage) {
if (isStreamingResponse || !lastAssistantMessage) {
return
}

Expand Down Expand Up @@ -94,7 +94,7 @@ export function useAutoPlayLastResponse({
}, [
ttsEnabled,
autoPlayEnabled,
hasActiveStream,
isStreamingResponse,
lastAssistantMessage,
lastAssistantText,
speakMessage,
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/hooks/useOpenCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,13 +436,13 @@ export const useAbortSession = (
const queryKey = ["opencode", "messages", opcodeUrl, targetSessionID, directory];
const messages = queryClient.getQueryData<MessageWithParts[]>(queryKey);

const hasActiveStream = messages?.some(msgWithParts => {
const hasIncompleteMessages = messages?.some(msgWithParts => {
if (msgWithParts.info.role !== "assistant") return false;
const assistantInfo = msgWithParts.info as AssistantMessage;
return !assistantInfo.time.completed;
});

return !hasActiveStream;
return !hasIncompleteMessages;
}, [queryClient, opcodeUrl, directory]);

useEffect(() => {
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/pages/SessionDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,13 @@ export function SessionDetail() {
const lastAssistantMessage = messages?.filter(m => m.info.role === 'assistant').at(-1);
const lastAssistantText = (lastAssistantMessage?.parts ?? []).filter(p => p.type === 'text').map(p => p.text).join('\n\n') || '';
const hasIncompleteMessages = lastAssistantMessage ? !('completed' in lastAssistantMessage.info.time && lastAssistantMessage.info.time.completed) : false;
const hasActiveStream = hasIncompleteMessages && isSessionActive;
const isStreamingResponse = hasIncompleteMessages && isSessionActive;

useAutoPlayLastResponse({
sessionId: sessionId ?? '',
lastAssistantMessage,
lastAssistantText,
hasActiveStream,
isStreamingResponse,
});

const handleShowModelsDialog = useCallback(() => setModelDialogOpen(true), []);
Expand Down Expand Up @@ -511,7 +511,7 @@ export function SessionDetail() {
>
<div className="relative w-[94%] md:max-w-4xl">
<div className="absolute -top-5 right-0 md:right-4 z-50 flex flex-col items-end gap-2">
{ttsEnabled && !hasPromptContent && !hasActiveStream && lastAssistantMessage && lastAssistantText && (
{ttsEnabled && !hasPromptContent && !isStreamingResponse && lastAssistantMessage && lastAssistantText && (
<FloatingTTSButton
messageId={lastAssistantMessage.info.id}
content={lastAssistantText}
Expand Down Expand Up @@ -562,7 +562,8 @@ export function SessionDetail() {
repoId={repoId}
disabled={!isConnected}
showScrollButton={showScrollButton}
hasActiveStream={hasActiveStream}
isSessionActive={isSessionActive}
isStreamingResponse={isStreamingResponse}
onScrollToBottom={scrollToBottom}
onShowModelsDialog={handleShowModelsDialog}
onShowSessionsDialog={handleShowSessionsDialog}
Expand Down
Loading