diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index fda525fa80..692da6dec8 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -34,7 +34,6 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::path::PathBuf; -use std::sync::atomic::Ordering; use std::sync::Arc; use tokio_util::sync::CancellationToken; use tracing::{error, warn}; @@ -209,8 +208,7 @@ async fn start_agent( } } - let counter = state.session_counter.fetch_add(1, Ordering::SeqCst) + 1; - let name = format!("New session {}", counter); + let name = "New Chat".to_string(); let manager = state.session_manager(); diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index 156f68a020..06620aeae8 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -4,7 +4,6 @@ use goose::scheduler_trait::SchedulerTrait; use goose::session::SessionManager; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; -use std::sync::atomic::AtomicUsize; use std::sync::Arc; use tokio::sync::Mutex; use tokio::task::JoinHandle; @@ -19,7 +18,6 @@ type ExtensionLoadingTasks = pub struct AppState { pub(crate) agent_manager: Arc, pub recipe_file_hash_map: Arc>>, - pub session_counter: Arc, /// Tracks sessions that have already emitted recipe telemetry to prevent double counting. recipe_session_tracker: Arc>>, pub tunnel_manager: Arc, @@ -34,7 +32,6 @@ impl AppState { Ok(Arc::new(Self { agent_manager, recipe_file_hash_map: Arc::new(Mutex::new(HashMap::new())), - session_counter: Arc::new(AtomicUsize::new(0)), recipe_session_tracker: Arc::new(Mutex::new(HashSet::new())), tunnel_manager, extension_loading_tasks: Arc::new(Mutex::new(HashMap::new())), diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 2c2cfba8a1..5c1f50f31c 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { IpcRendererEvent } from 'electron'; import { HashRouter, @@ -20,14 +20,18 @@ import { createSession } from './sessions'; import { ChatType } from './types/chat'; import Hub from './components/Hub'; -import Pair, { PairRouteState } from './components/Pair'; + +interface PairRouteState { + resumeSessionId?: string; + initialMessage?: string; +} import SettingsView, { SettingsViewOptions } from './components/settings/SettingsView'; import SessionsView from './components/sessions/SessionsView'; import SharedSessionView from './components/sessions/SharedSessionView'; import SchedulesView from './components/schedule/SchedulesView'; import ProviderSettings from './components/settings/providers/ProviderSettingsPage'; import { AppLayout } from './components/Layout/AppLayout'; -import { ChatProvider } from './contexts/ChatContext'; +import { ChatProvider, DEFAULT_CHAT_TITLE } from './contexts/ChatContext'; import LauncherView from './components/LauncherView'; import 'react-toastify/dist/ReactToastify.css'; @@ -47,6 +51,7 @@ import { errorMessage } from './utils/conversionUtils'; import { getInitialWorkingDir } from './utils/workingDir'; import { usePageViewTracking } from './hooks/useAnalytics'; import { trackOnboardingCompleted, trackErrorWithContext } from './utils/analytics'; +import { AppEvents } from './constants/events'; function PageViewTracker() { usePageViewTracking(); @@ -60,53 +65,28 @@ const HubRouteWrapper = () => { }; const PairRouteWrapper = ({ - chat, - setChat, + activeSessions, }: { - chat: ChatType; - setChat: (chat: ChatType) => void; + activeSessions: Array<{ sessionId: string; initialMessage?: string }>; + setActiveSessions: (sessions: Array<{ sessionId: string; initialMessage?: string }>) => void; }) => { const { extensionsList } = useConfig(); const location = useLocation(); - const navigate = useNavigate(); - const routeState = (location.state as PairRouteState) || {}; - const [searchParams] = useSearchParams(); + const routeState = + (location.state as PairRouteState) || (window.history.state as PairRouteState) || {}; + const [searchParams, setSearchParams] = useSearchParams(); const [isCreatingSession, setIsCreatingSession] = useState(false); - // Capture initialMessage in local state to survive route state being cleared - const [capturedInitialMessage, setCapturedInitialMessage] = useState( - undefined - ); - const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined; const recipeId = searchParams.get('recipeId') ?? undefined; const recipeDeeplinkFromConfig = window.appConfig?.get('recipeDeeplink') as string | undefined; - - // Session ID and initialMessage come from route state (Hub, fork) or URL params (refresh, deeplink) - const sessionIdFromState = routeState.resumeSessionId; - const sessionId = sessionIdFromState || resumeSessionId || chat.sessionId || undefined; - - // Use route state if available, otherwise use captured state - const initialMessage = routeState.initialMessage || capturedInitialMessage; - - // Capture initialMessage when it comes from route state - useEffect(() => { - console.log( - '[PairRouteWrapper] capture effect:', - JSON.stringify({ - routeStateInitialMessage: routeState.initialMessage, - }) - ); - if (routeState.initialMessage) { - setCapturedInitialMessage(routeState.initialMessage); - } - }, [routeState.initialMessage]); + const initialMessage = routeState.initialMessage; // Create session if we have an initialMessage, recipeId, or recipeDeeplink but no sessionId useEffect(() => { if ( (initialMessage || recipeId || recipeDeeplinkFromConfig) && - !sessionId && + !resumeSessionId && !isCreatingSession ) { setIsCreatingSession(true); @@ -118,9 +98,20 @@ const PairRouteWrapper = ({ recipeDeeplink: recipeDeeplinkFromConfig, allExtensions: extensionsList, }); - navigate(`/pair?resumeSessionId=${newSession.id}`, { - replace: true, - state: { resumeSessionId: newSession.id, initialMessage }, + + window.dispatchEvent( + new CustomEvent(AppEvents.ADD_ACTIVE_SESSION, { + detail: { + sessionId: newSession.id, + initialMessage, + }, + }) + ); + + setSearchParams((prev) => { + prev.set('resumeSessionId', newSession.id); + prev.delete('recipeId'); + return prev; }); } catch (error) { console.error('Failed to create session:', error); @@ -134,45 +125,33 @@ const PairRouteWrapper = ({ } })(); } + // Note: isCreatingSession is intentionally NOT in the dependency array + // It's only used as a guard to prevent concurrent session creation + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ initialMessage, recipeId, recipeDeeplinkFromConfig, - sessionId, - isCreatingSession, + resumeSessionId, + setSearchParams, extensionsList, - navigate, ]); - // Sync URL with session ID for refresh support (only if not already in URL) + // Add resumed session to active sessions if not already there useEffect(() => { - if (sessionId && sessionId !== resumeSessionId) { - navigate(`/pair?resumeSessionId=${sessionId}`, { - replace: true, - state: { resumeSessionId: sessionIdFromState, initialMessage }, - }); + if (resumeSessionId && !activeSessions.some((s) => s.sessionId === resumeSessionId)) { + window.dispatchEvent( + new CustomEvent(AppEvents.ADD_ACTIVE_SESSION, { + detail: { + sessionId: resumeSessionId, + initialMessage: initialMessage, + }, + }) + ); } - }, [sessionId, resumeSessionId, navigate, sessionIdFromState, initialMessage]); + }, [resumeSessionId, activeSessions, initialMessage]); - // Clear captured initialMessage when session changes (to prevent re-sending on navigation) - useEffect(() => { - if (sessionId && capturedInitialMessage && sessionIdFromState) { - const timer = setTimeout(() => { - setCapturedInitialMessage(undefined); - }, 100); - return () => clearTimeout(timer); - } - return undefined; - }, [sessionId, capturedInitialMessage, sessionIdFromState]); - - return ( - - ); + return null; }; const SettingsRoute = () => { @@ -366,11 +345,63 @@ export function AppInner() { const [chat, setChat] = useState({ sessionId: '', - name: 'Pair Chat', + name: DEFAULT_CHAT_TITLE, messages: [], recipe: null, }); + const MAX_ACTIVE_SESSIONS = 10; + + const [activeSessions, setActiveSessions] = useState< + Array<{ sessionId: string; initialMessage?: string }> + >([]); + + useEffect(() => { + const handleAddActiveSession = (event: Event) => { + const { sessionId, initialMessage } = ( + event as CustomEvent<{ sessionId: string; initialMessage?: string }> + ).detail; + + setActiveSessions((prev) => { + const existingIndex = prev.findIndex((s) => s.sessionId === sessionId); + + if (existingIndex !== -1) { + // Session exists - move to end of LRU list (most recently used) + const existing = prev[existingIndex]; + return [...prev.slice(0, existingIndex), ...prev.slice(existingIndex + 1), existing]; + } + + // New session - add to end with LRU eviction if needed + const newSession = { sessionId, initialMessage }; + const updated = [...prev, newSession]; + if (updated.length > MAX_ACTIVE_SESSIONS) { + return updated.slice(updated.length - MAX_ACTIVE_SESSIONS); + } + return updated; + }); + }; + + const handleClearInitialMessage = (event: Event) => { + const { sessionId } = (event as CustomEvent<{ sessionId: string }>).detail; + + setActiveSessions((prev) => { + return prev.map((session) => { + if (session.sessionId === sessionId) { + return { ...session, initialMessage: undefined }; + } + return session; + }); + }); + }; + + window.addEventListener(AppEvents.ADD_ACTIVE_SESSION, handleAddActiveSession); + window.addEventListener(AppEvents.CLEAR_INITIAL_MESSAGE, handleClearInitialMessage); + return () => { + window.removeEventListener(AppEvents.ADD_ACTIVE_SESSION, handleAddActiveSession); + window.removeEventListener(AppEvents.CLEAR_INITIAL_MESSAGE, handleClearInitialMessage); + }; + }, []); + const { addExtension } = useConfig(); useEffect(() => { @@ -515,6 +546,16 @@ export function AppInner() { return () => window.electron.off('set-view', handleSetView); }, [navigate]); + useEffect(() => { + const handleNewChat = (_event: IpcRendererEvent, ..._args: unknown[]) => { + console.log('Received new-chat event from keyboard shortcut'); + window.dispatchEvent(new CustomEvent(AppEvents.TRIGGER_NEW_CHAT)); + }; + + window.electron.on('new-chat', handleNewChat); + return () => window.electron.off('new-chat', handleNewChat); + }, []); + useEffect(() => { const handleFocusInput = (_event: IpcRendererEvent, ..._args: unknown[]) => { const inputField = document.querySelector('input[type="text"], textarea') as HTMLInputElement; @@ -529,22 +570,31 @@ export function AppInner() { }, []); // Handle initial message from launcher + const isProcessingRef = useRef(false); + useEffect(() => { const handleSetInitialMessage = async (_event: IpcRendererEvent, ...args: unknown[]) => { const initialMessage = args[0] as string; - if (initialMessage) { - console.log('Received initial message from launcher:', initialMessage); - try { - const session = await createSession(getInitialWorkingDir(), {}); - navigate('/pair', { - state: { - initialMessage, - resumeSessionId: session.id, - }, - }); - } catch (error) { - console.error('Failed to create session for launcher message:', error); - } + console.log( + '[App] Received set-initial-message event:', + initialMessage, + 'isProcessing:', + isProcessingRef.current + ); + + if (initialMessage && !isProcessingRef.current) { + isProcessingRef.current = true; + console.log('[App] Processing initial message from launcher:', initialMessage); + navigate('/pair', { + state: { + initialMessage, + }, + }); + setTimeout(() => { + isProcessingRef.current = false; + }, 1000); + } else if (initialMessage) { + console.log('[App] Ignoring duplicate initial message (already processing)'); } }; window.electron.on('set-initial-message', handleSetInitialMessage); @@ -578,52 +628,62 @@ export function AppInner() {
- - } /> - setDidSelectProvider(true)} />} - /> - } /> - } /> - - - - - - } - > - } /> - } /> - } /> +
+ + } /> - - - } + path="welcome" + element={ setDidSelectProvider(true)} />} /> - } /> - } /> - } /> - } /> + } /> + } /> + + + + + } - /> - } /> - - + > + } /> + + } + /> + } /> + + + + } + /> + } /> + } /> + } /> + } /> + + } + /> + } /> + + +
); diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 4d9ecbe079..944fc25cb9 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -1,3 +1,4 @@ +import { AppEvents } from '../constants/events'; import React, { createContext, useCallback, @@ -7,7 +8,7 @@ import React, { useRef, useState, } from 'react'; -import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { SearchView } from './conversation/SearchView'; import LoadingGoose from './LoadingGoose'; import PopularChatTopics from './PopularChatTopics'; @@ -36,11 +37,10 @@ import { substituteParameters } from '../utils/providerUtils'; import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal'; import { toastSuccess } from '../toasts'; import { Recipe } from '../recipe'; -import { createSession } from '../sessions'; -import { getInitialWorkingDir } from '../utils/workingDir'; -import { useConfig } from './ConfigContext'; +import { useAutoSubmit } from '../hooks/useAutoSubmit'; +import { Goose } from './icons/Goose'; +import EnvironmentBadge from './GooseSidebar/EnvironmentBadge'; -// Context for sharing current model info const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null); export const useCurrentModelInfo = () => useContext(CurrentModelContext); @@ -55,6 +55,7 @@ interface BaseChatProps { showPopularTopics?: boolean; suppressEmptyState: boolean; sessionId: string; + isActiveSession: boolean; initialMessage?: string; } @@ -65,37 +66,31 @@ function BaseChatContent({ customMainLayoutProps = {}, sessionId, initialMessage, + isActiveSession, }: BaseChatProps) { const location = useLocation(); const navigate = useNavigate(); - const [searchParams] = useSearchParams(); const scrollRef = useRef(null); - const { extensionsList } = useConfig(); + const chatInputRef = useRef(null); const disableAnimation = location.state?.disableAnimation || false; const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false); const [hasNotAcceptedRecipe, setHasNotAcceptedRecipe] = useState(); const [hasRecipeSecurityWarnings, setHasRecipeSecurityWarnings] = useState(false); - const [isCreatingSession, setIsCreatingSession] = useState(false); const isMobile = useIsMobile(); const { state: sidebarState } = useSidebar(); const setView = useNavigation(); - const contentClassName = cn('pr-1 pb-10', (isMobile || sidebarState === 'collapsed') && 'pt-11'); - - // Use shared file drop + const contentClassName = cn( + 'pr-1 pb-10 pt-10', + (isMobile || sidebarState === 'collapsed') && 'pt-14' + ); const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop(); const onStreamFinish = useCallback(() => {}, []); const [isCreateRecipeModalOpen, setIsCreateRecipeModalOpen] = useState(false); - const hasAutoSubmittedRef = useRef(false); - - // Reset auto-submit flag when session changes - useEffect(() => { - hasAutoSubmittedRef.current = false; - }, [sessionId]); const { session, @@ -115,6 +110,40 @@ function BaseChatContent({ onStreamFinish, }); + useAutoSubmit({ + sessionId, + session, + messages, + chatState, + initialMessage, + handleSubmit, + }); + + useEffect(() => { + let streamState: 'idle' | 'loading' | 'streaming' | 'error' = 'idle'; + if (chatState === ChatState.LoadingConversation) { + streamState = 'loading'; + } else if ( + chatState === ChatState.Streaming || + chatState === ChatState.Thinking || + chatState === ChatState.Compacting + ) { + streamState = 'streaming'; + } else if (sessionLoadError) { + streamState = 'error'; + } + + window.dispatchEvent( + new CustomEvent(AppEvents.SESSION_STATUS_UPDATE, { + detail: { + sessionId, + streamState, + messageCount: messages.length, + }, + }) + ); + }, [sessionId, chatState, messages.length, sessionLoadError]); + // Generate command history from user messages (most recent first) const commandHistory = useMemo(() => { return messages @@ -130,48 +159,10 @@ function BaseChatContent({ .reverse(); }, [messages]); - useEffect(() => { - if (!session || hasAutoSubmittedRef.current) { - return; - } - - const shouldStartAgent = searchParams.get('shouldStartAgent') === 'true'; - - if (initialMessage) { - hasAutoSubmittedRef.current = true; - handleSubmit(initialMessage); - // Clear initialMessage from navigation state to prevent re-sending on refresh - navigate(location.pathname + location.search, { - replace: true, - state: { ...location.state, initialMessage: undefined }, - }); - } else if (shouldStartAgent) { - hasAutoSubmittedRef.current = true; - handleSubmit(''); - } - }, [session, initialMessage, searchParams, handleSubmit, navigate, location]); - - const handleFormSubmit = async (e: React.FormEvent) => { + const handleFormSubmit = (e: React.FormEvent) => { const customEvent = e as unknown as CustomEvent; const textValue = customEvent.detail?.value || ''; - // If no session exists, create one and navigate with the initial message - if (!session && !sessionId && textValue.trim() && !isCreatingSession) { - setIsCreatingSession(true); - try { - const newSession = await createSession(getInitialWorkingDir(), { - allExtensions: extensionsList, - }); - navigate(`/pair?resumeSessionId=${newSession.id}`, { - replace: true, - state: { resumeSessionId: newSession.id, initialMessage: textValue }, - }); - } catch { - setIsCreatingSession(false); - } - return; - } - if (recipe && textValue.trim()) { setHasStartedUsingRecipe(true); } @@ -242,10 +233,26 @@ function BaseChatContent({ }, 200); }; - window.addEventListener('scroll-chat-to-bottom', handleGlobalScrollRequest); - return () => window.removeEventListener('scroll-chat-to-bottom', handleGlobalScrollRequest); + window.addEventListener(AppEvents.SCROLL_CHAT_TO_BOTTOM, handleGlobalScrollRequest); + return () => + window.removeEventListener(AppEvents.SCROLL_CHAT_TO_BOTTOM, handleGlobalScrollRequest); }, []); + useEffect(() => { + if ( + isActiveSession && + sessionId && + chatInputRef.current && + chatState !== ChatState.LoadingConversation + ) { + const timeoutId = setTimeout(() => { + chatInputRef.current?.focus(); + }, 100); + return () => clearTimeout(timeoutId); + } + return undefined; + }, [isActiveSession, sessionId, chatState]); + useEffect(() => { const handleMakeAgent = () => { setIsCreateRecipeModalOpen(true); @@ -262,6 +269,7 @@ function BaseChatContent({ shouldStartAgent?: boolean; editedMessage?: string; }>; + window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); const { newSessionId, shouldStartAgent, editedMessage } = customEvent.detail; const params = new URLSearchParams(); @@ -278,10 +286,10 @@ function BaseChatContent({ }); }; - window.addEventListener('session-forked', handleSessionForked); + window.addEventListener(AppEvents.SESSION_FORKED, handleSessionForked); return () => { - window.removeEventListener('session-forked', handleSessionForked); + window.removeEventListener(AppEvents.SESSION_FORKED, handleSessionForked); }; }, [location.pathname, navigate]); @@ -373,6 +381,12 @@ function BaseChatContent({ {/* Chat container with sticky recipe header */}
+
+ + goose + +
+ ) : !recipe && showPopularTopics ? ( - { - const syntheticEvent = { - detail: { value: text }, - preventDefault: () => {}, - } as unknown as React.FormEvent; - handleFormSubmit(syntheticEvent); - }} - /> + handleSubmit(text)} /> ) : null} @@ -449,6 +455,7 @@ function BaseChatContent({ className={`relative z-10 ${disableAnimation ? '' : 'animate-[fadein_400ms_ease-in_forwards]'}`} > void; onWorkingDirChange?: (newDir: string) => void; + inputRef?: React.RefObject; } export default function ChatInput({ @@ -127,6 +129,7 @@ export default function ChatInput({ toolCount, append: _append, onWorkingDirChange, + inputRef, }: ChatInputProps) { const [_value, setValue] = useState(initialValue); const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback @@ -298,30 +301,27 @@ export default function ChatInput({ }, }); - // Get dictation settings to check configuration status const { settings: dictationSettings } = useDictationSettings(); + const internalTextAreaRef = useRef(null); + const textAreaRef = inputRef || internalTextAreaRef; + const timeoutRefsRef = useRef>>(new Set()); - // Update internal value when initialValue changes useEffect(() => { setValue(initialValue); setDisplayValue(initialValue); - - // Use a functional update to get the current pastedImages - // and perform cleanup. This avoids needing pastedImages in the deps. setPastedImages((currentPastedImages) => { currentPastedImages.forEach((img) => { if (img.filePath) { window.electron.deleteTempFile(img.filePath); } }); - return []; // Return a new empty array + return []; }); - // Reset history index when input is cleared setHistoryIndex(-1); setIsInGlobalHistory(false); setHasUserTyped(false); - }, [initialValue]); // Keep only initialValue as a dependency + }, [initialValue]); // Handle recipe prompt updates useEffect(() => { @@ -333,16 +333,13 @@ export default function ChatInput({ textAreaRef.current?.focus(); }, 0); } - }, [recipeAccepted, initialPrompt, messages.length]); + }, [recipeAccepted, initialPrompt, messages.length, textAreaRef]); - // State to track if the IME is composing (i.e., in the middle of Japanese IME input) const [isComposing, setIsComposing] = useState(false); const [historyIndex, setHistoryIndex] = useState(-1); const [savedInput, setSavedInput] = useState(''); const [isInGlobalHistory, setIsInGlobalHistory] = useState(false); const [hasUserTyped, setHasUserTyped] = useState(false); - const textAreaRef = useRef(null); - const timeoutRefsRef = useRef>>(new Set()); // Use shared file drop hook for ChatInput const { @@ -410,7 +407,7 @@ export default function ChatInput({ if (textAreaRef.current) { textAreaRef.current.focus(); } - }, []); + }, [textAreaRef]); // Load model limits from the API const getModelLimits = async () => { @@ -514,7 +511,7 @@ export default function ChatInput({ showCompactButton: true, compactButtonDisabled: !totalTokens, onCompact: () => { - window.dispatchEvent(new CustomEvent('hide-alert-popover')); + window.dispatchEvent(new CustomEvent(AppEvents.HIDE_ALERT_POPOVER)); const customEvent = new CustomEvent('submit', { detail: { value: MANUAL_COMPACT_TRIGGER }, @@ -579,28 +576,38 @@ export default function ChatInput({ setValue(value); }, []); + const minTextareaHeight = 38; + const debouncedAutosize = useMemo( () => debounce((element: HTMLTextAreaElement) => { - element.style.height = '0px'; // Reset height + // Store current scroll position to prevent jump + const scrollTop = element.scrollTop; + + // Temporarily set to auto to measure natural height, but use minHeight to prevent collapse + element.style.height = `${minTextareaHeight}px`; const scrollHeight = element.scrollHeight; - element.style.height = Math.min(scrollHeight, maxHeight) + 'px'; + const newHeight = Math.max(minTextareaHeight, Math.min(scrollHeight, maxHeight)); + element.style.height = `${newHeight}px`; + + // Restore scroll position + element.scrollTop = scrollTop; }, 50), - [maxHeight] + [maxHeight, minTextareaHeight] ); useEffect(() => { if (textAreaRef.current) { debouncedAutosize(textAreaRef.current); } - }, [debouncedAutosize, displayValue]); + }, [debouncedAutosize, displayValue, textAreaRef]); - // Reset textarea height when displayValue is empty + // Set consistent minimum height when displayValue is empty useEffect(() => { if (textAreaRef.current && displayValue === '') { - textAreaRef.current.style.height = 'auto'; + textAreaRef.current.style.height = `${minTextareaHeight}px`; } - }, [displayValue]); + }, [displayValue, textAreaRef, minTextareaHeight]); const handleChange = (evt: React.ChangeEvent) => { const val = evt.target.value; @@ -1146,6 +1153,16 @@ export default function ChatInput({ isTranscribing || chatState === ChatState.RestartingAgent; + const getSubmitButtonTooltip = (): string => { + if (isAnyImageLoading) return 'Waiting for images to save...'; + if (isAnyDroppedFileLoading) return 'Processing dropped files...'; + if (isRecording) return 'Recording...'; + if (isTranscribing) return 'Transcribing...'; + if (chatState === ChatState.RestartingAgent) return 'Restarting session...'; + if (!hasSubmittableContent) return 'Type a message to send'; + return 'Send'; + }; + // Queue management functions - no storage persistence, only in-memory const handleRemoveQueuedMessage = (messageId: string) => { setQueuedMessages((prev) => prev.filter((msg) => msg.id !== messageId)); @@ -1243,8 +1260,8 @@ export default function ChatInput({ /> )} {/* Input row with inline action buttons wrapped in form */} -
-
+ +