diff --git a/src/App.tsx b/src/App.tsx index fccdce1..9a25c29 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -138,6 +138,7 @@ function ChatArea({ chatInputRef, onOpenSettings }: ChatAreaProps) { const { piiNotification, piiScanError, memoryNotice } = useConversationMeta(); const { sendMessage, + stopStreaming, retryMessage, startNewConversation, clearPiiNotification, @@ -239,6 +240,8 @@ function ChatArea({ chatInputRef, onOpenSettings }: ChatAreaProps) { onSubmit={handleSubmit} disabled={isLoading || isAtMessageLimit} isOffline={isOffline} + isStreaming={isLoading} + onStop={stopStreaming} /> ); diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 08929e8..3e0dfec 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react'; +import { resolveChatInputMode } from '../../lib/chat-input-state'; interface ChatInputProps { /** Callback when user submits a message (called with trimmed text) */ @@ -11,6 +12,10 @@ interface ChatInputProps { placeholder?: string; /** Auto-focus on mount */ autoFocus?: boolean; + /** #147: a response is streaming — the Send button becomes a Stop button. */ + isStreaming?: boolean; + /** #147: called when the user hits Stop to cancel the in-flight stream. */ + onStop?: () => void; } /** Handle exposed via ref for external focus control */ @@ -27,6 +32,8 @@ export const ChatInput = forwardRef(function Ch isOffline = false, placeholder = 'Ask a question...', autoFocus = true, + isStreaming = false, + onStop, }, ref ) { @@ -40,7 +47,16 @@ export const ChatInput = forwardRef(function Ch // Combine disabled states - offline also disables submit const isInputDisabled = disabled; - const isSubmitDisabled = disabled || isOffline || !message.trim(); + // #147: while streaming, the button becomes a Stop; otherwise it is a Send + // whose enablement follows text/disabled/offline. Logic is a pure, tested fn. + const buttonMode = resolveChatInputMode({ + isStreaming, + canStop: !!onStop, + hasText: !!message.trim(), + disabled, + isOffline, + }); + const isSubmitDisabled = buttonMode === 'send-disabled'; // Dynamic placeholder for offline state const effectivePlaceholder = isOffline @@ -141,38 +157,59 @@ export const ChatInput = forwardRef(function Ch ${isInputDisabled ? 'cursor-not-allowed' : ''} `} /> - + {/* Solid square = stop */} + + + ) : ( + + )} ); diff --git a/src/contexts/ConversationContext.tsx b/src/contexts/ConversationContext.tsx index 72437cd..bc3e78f 100644 --- a/src/contexts/ConversationContext.tsx +++ b/src/contexts/ConversationContext.tsx @@ -13,7 +13,7 @@ import { } from 'react'; import { listen, UnlistenFn } from '@tauri-apps/api/event'; import type { Message } from '../lib/types'; -import { categorizeError } from '../lib/error-utils'; +import { categorizeError, isCancelledError } from '../lib/error-utils'; import { appendChunk, setMessageError, @@ -29,6 +29,7 @@ import { searchConversations as searchConversationsApi, generateConversationTitle, sendChatMessageStreaming, + cancelStream, getSystemPrompt, generateConversationSummary, saveConversationSummary, @@ -60,6 +61,7 @@ interface ConversationContextValue { // Actions sendMessage: (content: string, selectedEmployeeId?: string | null) => Promise; + stopStreaming: () => void; retryMessage: (messageId: string) => Promise; loadConversation: (id: string) => Promise; startNewConversation: () => Promise; @@ -103,6 +105,7 @@ interface ConversationDirectoryContextValue { interface ConversationActionsContextValue { sendMessage: (content: string, selectedEmployeeId?: string | null) => Promise; + stopStreaming: () => void; retryMessage: (messageId: string) => Promise; loadConversation: (id: string) => Promise; startNewConversation: () => Promise; @@ -157,6 +160,9 @@ export function ConversationProvider({ children }: ConversationProviderProps) { const [isLoading, setIsLoading] = useState(false); const [currentTitle, setCurrentTitle] = useState(null); const streamingMessageId = useRef(null); + // #147: the client-generated id of the in-flight stream, so the UI can + // cancel it (Stop button, conversation switch, unmount). Null when idle. + const activeStreamIdRef = useRef(null); // Ref to track current conversationId for async/stream handlers (avoids stale closures) const conversationIdRef = useRef(conversationId); @@ -396,8 +402,15 @@ export function ConversationProvider({ children }: ConversationProviderProps) { }; setMessages((prev) => [...prev, assistantMessage]); - // Set up stream event listener + // #147: client-generated id for this stream so the user can cancel it. + const streamId = crypto.randomUUID(); + activeStreamIdRef.current = streamId; + + // Set up stream event listeners let unlisten: UnlistenFn | null = null; + // #147: separate listener for the backend's cancelled signal (chat.rs emits + // "chat-stream-cancelled" and NOT a normal `done`, so the reset lives here). + let unlistenCancel: UnlistenFn | null = null; // Stream inactivity timeout — resets on every chunk, fires error if stream stalls let streamTimeoutId: number | null = null; @@ -408,11 +421,13 @@ export function ConversationProvider({ children }: ConversationProviderProps) { streamTimeoutId = null; flushBufferedChunkNow(); if (unlisten) { unlisten(); unlisten = null; } + if (unlistenCancel) { unlistenCancel(); unlistenCancel = null; } const chatError = categorizeError(new Error('Response timed out — no data received for 30 seconds. The AI provider may be experiencing issues.')); chatError.originalContent = content; setMessages((prev) => setMessageError(prev, assistantId, chatError)); streamingMessageId.current = null; + activeStreamIdRef.current = null; setIsLoading(false); }, STREAM_TIMEOUT_MS); }; @@ -478,6 +493,7 @@ export function ConversationProvider({ children }: ConversationProviderProps) { accumulatedResponseRef.current = ''; streamingMessageId.current = null; + activeStreamIdRef.current = null; setIsLoading(false); } else { // Accumulate response for audit logging @@ -487,6 +503,18 @@ export function ConversationProvider({ children }: ConversationProviderProps) { } }); + // #147: user hit Stop (or we cancelled on switch/unmount). The backend + // dropped the upstream connection and emitted this instead of `done`. + // Finalize cleanly: keep whatever streamed so far, reset streaming state. + unlistenCancel = await listen('chat-stream-cancelled', () => { + clearStreamTimeout(); + flushBufferedChunkNow(); + accumulatedResponseRef.current = ''; + streamingMessageId.current = null; + activeStreamIdRef.current = null; + setIsLoading(false); + }); + // Build message history for API const currentMessages = await new Promise((resolve) => { setMessages((prev) => { @@ -518,28 +546,74 @@ export function ConversationProvider({ children }: ConversationProviderProps) { promptResult.aggregates, promptResult.query_type, conversationIdRef.current, - promptResult.employee_ids_used + promptResult.employee_ids_used, + streamId ); } catch (error) { clearStreamTimeout(); flushBufferedChunkNow(); - // Categorize error for user-friendly display - const chatError = categorizeError(error); - chatError.originalContent = content; + if (isCancelledError(error)) { + // #147: Stop is a user action, not a failure. The backend rejects the + // invoke with ChatError::Cancelled *in addition to* emitting + // "chat-stream-cancelled" — without this branch the rejection would + // decorate the partial message with a generic error + retry chip. + // Finalize quietly (idempotent with the event handler, whichever + // lands first): keep the partial text, reset streaming state. + accumulatedResponseRef.current = ''; + streamingMessageId.current = null; + setIsLoading(false); + } else { + // Categorize error for user-friendly display + const chatError = categorizeError(error); + chatError.originalContent = content; - // Update assistant message with error state - setMessages((prev) => setMessageError(prev, assistantId, chatError)); - setIsLoading(false); + // Update assistant message with error state + setMessages((prev) => setMessageError(prev, assistantId, chatError)); + setIsLoading(false); + } } finally { flushBufferedChunkNow(); if (unlisten) { unlisten(); } + if (unlistenCancel) { + unlistenCancel(); + } + // #147: if this send is still the active stream (e.g. it errored without + // a done/cancelled event), clear the id so a later Stop/switch doesn't + // try to cancel a dead stream. + if (activeStreamIdRef.current === streamId) { + activeStreamIdRef.current = null; + } } }, [conversationId]); + // --------------------------------------------------------------------------- + // Stop the in-flight stream (#147). Drives the UI reset via the backend's + // "chat-stream-cancelled" event rather than mutating state here, so the same + // path handles Stop, conversation switch, and unmount. Safe when idle. + // --------------------------------------------------------------------------- + const stopStreaming = useCallback(() => { + const id = activeStreamIdRef.current; + if (!id) return; + cancelStream(id).catch((err) => { + console.error('[Conversation] Failed to cancel stream:', err); + }); + }, []); + + // #147: cancel any in-flight stream if the provider unmounts, so an + // abandoned stream stops billing instead of running to completion. + useEffect(() => { + return () => { + const id = activeStreamIdRef.current; + if (id) { + cancelStream(id).catch(() => {}); + } + }; + }, []); + // --------------------------------------------------------------------------- // Retry a failed message // --------------------------------------------------------------------------- @@ -564,6 +638,7 @@ export function ConversationProvider({ children }: ConversationProviderProps) { // Load a conversation from database // --------------------------------------------------------------------------- const loadConversation = useCallback(async (id: string) => { + stopStreaming(); // #147: abandon any in-flight stream when switching away try { const conversation = await getConversation(id); @@ -586,12 +661,13 @@ export function ConversationProvider({ children }: ConversationProviderProps) { console.error('[Conversation] Failed to load:', err); throw err; } - }, []); + }, [stopStreaming]); // --------------------------------------------------------------------------- // Start a new conversation // --------------------------------------------------------------------------- const startNewConversation = useCallback(async () => { + stopStreaming(); // #147: abandon any in-flight stream when switching away // Generate summary if current conversation has enough content const userMessages = messages.filter(m => m.role === 'user'); const assistantMessages = messages.filter(m => m.role === 'assistant' && m.content.length > 0); @@ -628,7 +704,7 @@ export function ConversationProvider({ children }: ConversationProviderProps) { // Refresh list to show any saved conversation await refreshConversations(); - }, [messages, conversationId, refreshConversations]); + }, [messages, conversationId, refreshConversations, stopStreaming]); // --------------------------------------------------------------------------- // Delete a conversation @@ -689,6 +765,7 @@ export function ConversationProvider({ children }: ConversationProviderProps) { const actionsValue = useMemo( () => ({ sendMessage, + stopStreaming, retryMessage, loadConversation, startNewConversation, @@ -701,6 +778,7 @@ export function ConversationProvider({ children }: ConversationProviderProps) { }), [ sendMessage, + stopStreaming, retryMessage, loadConversation, startNewConversation, diff --git a/src/lib/chat-input-state.test.ts b/src/lib/chat-input-state.test.ts new file mode 100644 index 0000000..92d3c3d --- /dev/null +++ b/src/lib/chat-input-state.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { resolveChatInputMode } from './chat-input-state'; + +// Unit coverage for the send/stop decision extracted in #147. The load-bearing +// behavior: a cancellable in-flight stream shows Stop; everything else is a +// Send button whose enablement follows text + disabled + offline. + +const base = { + isStreaming: false, + canStop: false, + hasText: false, + disabled: false, + isOffline: false, +}; + +describe('resolveChatInputMode — streaming shows Stop', () => { + it('streaming with a stop handler → stop (even with no text)', () => { + expect(resolveChatInputMode({ ...base, isStreaming: true, canStop: true })).toBe('stop'); + }); + + it('streaming with a stop handler → stop, ignoring disabled/offline', () => { + expect( + resolveChatInputMode({ ...base, isStreaming: true, canStop: true, disabled: true, isOffline: true }) + ).toBe('stop'); + }); + + it('streaming but no stop handler wired → falls through to send rules', () => { + // No handler means Stop cannot do anything; with no text it is a disabled Send. + expect(resolveChatInputMode({ ...base, isStreaming: true, canStop: false })).toBe('send-disabled'); + }); +}); + +describe('resolveChatInputMode — not streaming behaves as a Send button', () => { + it('has text, enabled, online → send', () => { + expect(resolveChatInputMode({ ...base, hasText: true })).toBe('send'); + }); + + it('empty input → send-disabled', () => { + expect(resolveChatInputMode({ ...base, hasText: false })).toBe('send-disabled'); + }); + + it('disabled (e.g. loading / at message limit) → send-disabled even with text', () => { + expect(resolveChatInputMode({ ...base, hasText: true, disabled: true })).toBe('send-disabled'); + }); + + it('offline → send-disabled even with text', () => { + expect(resolveChatInputMode({ ...base, hasText: true, isOffline: true })).toBe('send-disabled'); + }); +}); diff --git a/src/lib/chat-input-state.ts b/src/lib/chat-input-state.ts new file mode 100644 index 0000000..8ea826a --- /dev/null +++ b/src/lib/chat-input-state.ts @@ -0,0 +1,47 @@ +// People Partner - Chat input button-state logic +// Extracted as a pure function so the send/stop decision is unit-testable +// without rendering the component (#147). + +/** Which affordance the chat input should present. */ +export type ChatInputMode = + /** A response is streaming and can be cancelled — show a Stop button. */ + | 'stop' + /** Ready to send — show an enabled Send button. */ + | 'send' + /** Nothing to send yet (empty/disabled/offline) — show a disabled Send button. */ + | 'send-disabled'; + +export interface ChatInputStateArgs { + /** A response is currently streaming in. */ + isStreaming: boolean; + /** A stop handler is wired up (Stop is only meaningful when it is). */ + canStop: boolean; + /** The trimmed input has content. */ + hasText: boolean; + /** External disable (loading, at message limit, etc.). */ + disabled: boolean; + /** Offline — sending is unavailable. */ + isOffline: boolean; +} + +/** + * Decide which button the chat input renders. + * + * Stop wins while a cancellable stream is in flight; otherwise the input is a + * Send button, enabled only when there is text and no external block. Keeping + * this pure means the streaming-cancel wiring in #147 is covered by fast unit + * tests rather than only through the full component. + */ +export function resolveChatInputMode(args: ChatInputStateArgs): ChatInputMode { + const { isStreaming, canStop, hasText, disabled, isOffline } = args; + + if (isStreaming && canStop) { + return 'stop'; + } + + if (disabled || isOffline || !hasText) { + return 'send-disabled'; + } + + return 'send'; +} diff --git a/src/lib/error-utils.test.ts b/src/lib/error-utils.test.ts index 22fc08b..db23978 100644 --- a/src/lib/error-utils.test.ts +++ b/src/lib/error-utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { categorizeError } from './error-utils'; +import { categorizeError, isCancelledError } from './error-utils'; // Characterization tests for chat error categorization (#110, #108). // These lock the pattern table AND its ORDER — the ordering is load-bearing: @@ -94,3 +94,17 @@ describe('categorizeError — input shapes', () => { expect(categorizeError(undefined).type).toBe('unknown'); }); }); + +describe('isCancelledError — Stop is not a failure (#147)', () => { + it('matches the backend ChatError::Cancelled rejection in both shapes', () => { + expect(isCancelledError(new Error('Stream cancelled'))).toBe(true); + expect(isCancelledError('Stream cancelled')).toBe(true); + }); + + it('does not match real errors or empty input', () => { + expect(isCancelledError(new Error('API returned error: 500'))).toBe(false); + expect(isCancelledError('Rate limit reached')).toBe(false); + expect(isCancelledError(null)).toBe(false); + expect(isCancelledError(undefined)).toBe(false); + }); +}); diff --git a/src/lib/error-utils.ts b/src/lib/error-utils.ts index 14231ce..548c505 100644 --- a/src/lib/error-utils.ts +++ b/src/lib/error-utils.ts @@ -79,6 +79,17 @@ const ERROR_PATTERNS: ErrorPattern[] = [ }, ]; +/** + * True when an error is the backend's stream-cancellation rejection + * (`ChatError::Cancelled` → "Stream cancelled"). Cancellation is a user + * action, not a failure — callers must finalize quietly instead of routing + * it through categorizeError's error UI (#147). + */ +export function isCancelledError(error: unknown): boolean { + const errorStr = error instanceof Error ? error.message : String(error); + return errorStr.includes('Stream cancelled'); +} + /** * Categorizes an error into a user-friendly ChatError object. * Pattern matches on backend error strings to determine type and messaging. diff --git a/src/lib/tauri-commands.ts b/src/lib/tauri-commands.ts index 348ebdb..98005af 100644 --- a/src/lib/tauri-commands.ts +++ b/src/lib/tauri-commands.ts @@ -244,8 +244,12 @@ export async function sendChatMessageStreaming( aggregates?: OrgAggregates | null, queryType?: QueryType | null, conversationId?: string | null, - employeeIdsUsed?: string[] + employeeIdsUsed?: string[], + streamId?: string | null ): Promise { + // #147: pass a client-generated stream id so the UI can cancel this stream + // via cancelStream(). When omitted the backend generates one the UI can + // never learn (and therefore can never cancel). return invoke('send_chat_message_streaming', { messages, systemPrompt: systemPrompt ?? null, @@ -253,9 +257,19 @@ export async function sendChatMessageStreaming( queryType: queryType ?? null, conversationId: conversationId ?? null, employeeIdsUsed: employeeIdsUsed ?? null, + streamId: streamId ?? null, }); } +/** + * Cancel an in-flight streaming response by its client-generated stream id + * (the same id passed to {@link sendChatMessageStreaming}). Safe to call with + * an unknown/already-finished id — the backend returns false. #147 / backend #25. + */ +export async function cancelStream(streamId: string): Promise { + return invoke('cancel_stream', { streamId }); +} + /** Event payload for streaming chunks */ export interface StreamChunk { chunk: string;