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
3 changes: 3 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ function ChatArea({ chatInputRef, onOpenSettings }: ChatAreaProps) {
const { piiNotification, piiScanError, memoryNotice } = useConversationMeta();
const {
sendMessage,
stopStreaming,
retryMessage,
startNewConversation,
clearPiiNotification,
Expand Down Expand Up @@ -239,6 +240,8 @@ function ChatArea({ chatInputRef, onOpenSettings }: ChatAreaProps) {
onSubmit={handleSubmit}
disabled={isLoading || isAtMessageLimit}
isOffline={isOffline}
isStreaming={isLoading}
onStop={stopStreaming}
/>
</div>
);
Expand Down
101 changes: 69 additions & 32 deletions src/components/chat/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -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) */
Expand All @@ -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 */
Expand All @@ -27,6 +32,8 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
isOffline = false,
placeholder = 'Ask a question...',
autoFocus = true,
isStreaming = false,
onStop,
},
ref
) {
Expand All @@ -40,7 +47,16 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(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
Expand Down Expand Up @@ -141,38 +157,59 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
${isInputDisabled ? 'cursor-not-allowed' : ''}
`}
/>
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitDisabled}
aria-label="Send message"
className={`
w-9 h-9
flex-shrink-0
flex items-center justify-center
rounded-lg
transition-all duration-200
${
isSubmitDisabled
? 'bg-stone-200 text-stone-400 cursor-not-allowed'
: 'bg-primary-500 hover:bg-primary-600 text-white shadow-sm hover:shadow-md hover:brightness-110 active:brightness-95'
}
`}
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
{buttonMode === 'stop' ? (
<button
type="button"
onClick={onStop}
aria-label="Stop generating"
className="
w-9 h-9
flex-shrink-0
flex items-center justify-center
rounded-lg
transition-all duration-200
bg-stone-600 hover:bg-stone-700 text-white shadow-sm hover:shadow-md active:brightness-95
"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
/>
</svg>
</button>
{/* Solid square = stop */}
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
</button>
) : (
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitDisabled}
aria-label="Send message"
className={`
w-9 h-9
flex-shrink-0
flex items-center justify-center
rounded-lg
transition-all duration-200
${
isSubmitDisabled
? 'bg-stone-200 text-stone-400 cursor-not-allowed'
: 'bg-primary-500 hover:bg-primary-600 text-white shadow-sm hover:shadow-md hover:brightness-110 active:brightness-95'
}
`}
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
/>
</svg>
</button>
)}
</div>
</div>
);
Expand Down
100 changes: 89 additions & 11 deletions src/contexts/ConversationContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,6 +29,7 @@ import {
searchConversations as searchConversationsApi,
generateConversationTitle,
sendChatMessageStreaming,
cancelStream,
getSystemPrompt,
generateConversationSummary,
saveConversationSummary,
Expand Down Expand Up @@ -60,6 +61,7 @@ interface ConversationContextValue {

// Actions
sendMessage: (content: string, selectedEmployeeId?: string | null) => Promise<void>;
stopStreaming: () => void;
retryMessage: (messageId: string) => Promise<void>;
loadConversation: (id: string) => Promise<void>;
startNewConversation: () => Promise<void>;
Expand Down Expand Up @@ -103,6 +105,7 @@ interface ConversationDirectoryContextValue {

interface ConversationActionsContextValue {
sendMessage: (content: string, selectedEmployeeId?: string | null) => Promise<void>;
stopStreaming: () => void;
retryMessage: (messageId: string) => Promise<void>;
loadConversation: (id: string) => Promise<void>;
startNewConversation: () => Promise<void>;
Expand Down Expand Up @@ -157,6 +160,9 @@ export function ConversationProvider({ children }: ConversationProviderProps) {
const [isLoading, setIsLoading] = useState(false);
const [currentTitle, setCurrentTitle] = useState<string | null>(null);
const streamingMessageId = useRef<string | null>(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<string | null>(null);

// Ref to track current conversationId for async/stream handlers (avoids stale closures)
const conversationIdRef = useRef(conversationId);
Expand Down Expand Up @@ -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;
Expand All @@ -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);
};
Expand Down Expand Up @@ -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
Expand All @@ -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<Message[]>((resolve) => {
setMessages((prev) => {
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand All @@ -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);

Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -689,6 +765,7 @@ export function ConversationProvider({ children }: ConversationProviderProps) {
const actionsValue = useMemo<ConversationActionsContextValue>(
() => ({
sendMessage,
stopStreaming,
retryMessage,
loadConversation,
startNewConversation,
Expand All @@ -701,6 +778,7 @@ export function ConversationProvider({ children }: ConversationProviderProps) {
}),
[
sendMessage,
stopStreaming,
retryMessage,
loadConversation,
startNewConversation,
Expand Down
Loading