diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 4b92e1ec..1fc8424a 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -12,6 +12,7 @@ import { useMenuActions } from '@/hooks/useMenuActions'; import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap'; import { useServerSessionStatus } from '@/hooks/useServerSessionStatus'; import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup'; +import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend'; import { useRouter } from '@/hooks/useRouter'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { GitPollingProvider } from '@/hooks/useGitPolling'; @@ -203,6 +204,7 @@ function App({ apis }: AppProps) { useSessionStatusBootstrap(); useSessionAutoCleanup(); + useQueuedMessageAutoSend(); React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 22389f94..af453032 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -136,7 +136,6 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const acknowledgeSessionAbort = useSessionStore((state) => state.acknowledgeSessionAbort); const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId); const clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt); - const sessionAbortFlags = useSessionStore((state) => state.sessionAbortFlags); const attachedFiles = useSessionStore((state) => state.attachedFiles); const addAttachedFile = useSessionStore((state) => state.addAttachedFile); const addServerFile = useSessionStore((state) => state.addServerFile); @@ -297,10 +296,8 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo saveStoredDraft(currentSessionId, message); }, [message, persistChatDraft, currentSessionId]); - // Session activity for auto-send on idle + // Session activity for queue availability and controls const { phase: sessionPhase } = useCurrentSessionActivity(); - const prevSessionPhaseRef = React.useRef(sessionPhase); - const autoSendTriggeredRef = React.useRef(false); const handleTextareaPointerDownCapture = React.useCallback((event: React.PointerEvent) => { if (!isMobile) { @@ -689,45 +686,6 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } }, [inputMode, hasContent, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage]); - // Auto-send queued messages when session becomes idle (but not after abort) - React.useEffect(() => { - const wasWorking = prevSessionPhaseRef.current === 'busy' || prevSessionPhaseRef.current === 'retry'; - const isNowIdle = sessionPhase === 'idle'; - - // Check if session was recently aborted (within last 2 seconds) - const wasRecentlyAborted = currentSessionId && sessionAbortFlags.has(currentSessionId) && (() => { - const abortRecord = sessionAbortFlags.get(currentSessionId); - if (!abortRecord) return false; - const timeSinceAbort = Date.now() - abortRecord.timestamp; - return timeSinceAbort < 2000; - })(); - - // Detect transition from working to idle, but skip if aborted - if (wasWorking && isNowIdle && queuedMessages.length > 0 && !autoSendTriggeredRef.current && !wasRecentlyAborted) { - // Prevent double-triggering - autoSendTriggeredRef.current = true; - - const targetSessionId = currentSessionId; - - // Use setTimeout to avoid calling during render - setTimeout(() => { - const activeSessionId = useSessionStore.getState().currentSessionId; - const currentStatus = targetSessionId - ? useSessionStore.getState().sessionStatus?.get(targetSessionId) - : null; - const stillIdle = currentStatus?.type === 'idle'; - const sessionUnchanged = Boolean(targetSessionId) && activeSessionId === targetSessionId; - - if (sessionUnchanged && stillIdle && targetSessionId && currentProviderId && currentModelId) { - void handleSubmitRef.current({ queuedOnly: true }); - } - autoSendTriggeredRef.current = false; - }, 100); - } - - prevSessionPhaseRef.current = sessionPhase; - }, [sessionPhase, queuedMessages.length, currentSessionId, currentProviderId, currentModelId, sessionAbortFlags]); - const handleKeyDown = (e: React.KeyboardEvent) => { // Early return during IME composition to prevent interference with autocomplete. // Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown. diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts new file mode 100644 index 00000000..15ff5149 --- /dev/null +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts @@ -0,0 +1,192 @@ +import React from 'react'; +import type { AttachedFile } from '@/stores/types/sessionTypes'; +import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useMessageStore } from '@/stores/messageStore'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useContextStore } from '@/stores/contextStore'; +import { parseAgentMentions } from '@/lib/messages/agentMentions'; + +type SessionStatusType = 'idle' | 'busy' | 'retry'; + +const RECENT_ABORT_WINDOW_MS = 2000; + +const hasRecentAbort = (sessionId: string): boolean => { + const abortRecord = useSessionStore.getState().sessionAbortFlags.get(sessionId); + if (!abortRecord) { + return false; + } + return Date.now() - abortRecord.timestamp < RECENT_ABORT_WINDOW_MS; +}; + +const setSessionStatus = (sessionId: string, type: SessionStatusType) => { + useSessionStore.setState((state) => { + const next = new Map(state.sessionStatus ?? new Map()); + next.set(sessionId, { type }); + return { sessionStatus: next }; + }); +}; + +const buildQueuedPayload = (queue: QueuedMessage[]) => { + const agents = useConfigStore.getState().getVisibleAgents(); + let primaryText = ''; + let primaryAttachments: AttachedFile[] = []; + let agentMentionName: string | undefined; + const additionalParts: Array<{ text: string; attachments?: AttachedFile[] }> = []; + + for (let i = 0; i < queue.length; i += 1) { + const queued = queue[i]; + const { sanitizedText, mention } = parseAgentMentions(queued.content, agents); + + if (!agentMentionName && mention?.name) { + agentMentionName = mention.name; + } + + if (i === 0) { + primaryText = sanitizedText; + primaryAttachments = queued.attachments ?? []; + } else { + additionalParts.push({ + text: sanitizedText, + attachments: queued.attachments, + }); + } + } + + return { + primaryText, + primaryAttachments, + agentMentionName, + additionalParts: additionalParts.length > 0 ? additionalParts : undefined, + }; +}; + +const resolveSessionSendConfig = (sessionId: string) => { + const context = useContextStore.getState(); + const config = useConfigStore.getState(); + const message = useMessageStore.getState(); + + const selectedAgent = + context.getSessionAgentSelection(sessionId) + ?? context.getCurrentAgent(sessionId) + ?? config.currentAgentName + ?? undefined; + + const sessionModel = context.getSessionModelSelection(sessionId); + const agentModel = selectedAgent + ? context.getAgentModelForSession(sessionId, selectedAgent) + : null; + + const providerID = + agentModel?.providerId + ?? sessionModel?.providerId + ?? config.currentProviderId + ?? message.lastUsedProvider?.providerID; + const modelID = + agentModel?.modelId + ?? sessionModel?.modelId + ?? config.currentModelId + ?? message.lastUsedProvider?.modelID; + + const variant = + selectedAgent && providerID && modelID + ? context.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID) + : undefined; + + return { + providerID, + modelID, + agent: selectedAgent, + variant, + }; +}; + +export function useQueuedMessageAutoSend() { + const queuedMessages = useMessageQueueStore((state) => state.queuedMessages); + const sessionStatus = useSessionStore((state) => state.sessionStatus); + + const inFlightSessionsRef = React.useRef>(new Set()); + const previousStatusRef = React.useRef>(new Map()); + + React.useEffect(() => { + const dispatchSessionQueue = async (sessionId: string, queueSnapshot: QueuedMessage[]) => { + if (queueSnapshot.length === 0) { + return; + } + if (inFlightSessionsRef.current.has(sessionId)) { + return; + } + if (hasRecentAbort(sessionId)) { + return; + } + + const currentStatus = useSessionStore.getState().sessionStatus?.get(sessionId)?.type ?? 'idle'; + if (currentStatus !== 'idle') { + return; + } + + const payload = buildQueuedPayload(queueSnapshot); + if (!payload.primaryText && !payload.additionalParts?.length) { + return; + } + + const resolved = resolveSessionSendConfig(sessionId); + if (!resolved.providerID || !resolved.modelID) { + return; + } + + inFlightSessionsRef.current.add(sessionId); + setSessionStatus(sessionId, 'busy'); + + try { + await useMessageStore.getState().sendMessage( + payload.primaryText, + resolved.providerID, + resolved.modelID, + resolved.agent, + sessionId, + payload.primaryAttachments, + payload.agentMentionName, + payload.additionalParts, + resolved.variant, + 'normal' + ); + + const removeFromQueue = useMessageQueueStore.getState().removeFromQueue; + queueSnapshot.forEach((item) => { + removeFromQueue(sessionId, item.id); + }); + } catch (error) { + setSessionStatus(sessionId, 'idle'); + console.warn('[queue] queued auto-send failed:', error); + } finally { + inFlightSessionsRef.current.delete(sessionId); + } + }; + + const nextStatusMap = new Map(previousStatusRef.current); + const statusEntries = sessionStatus ? Array.from(sessionStatus.entries()) : []; + statusEntries.forEach(([sessionId, status]) => { + nextStatusMap.set(sessionId, status.type); + }); + + const queueEntries = Object.entries(queuedMessages); + queueEntries.forEach(([sessionId, queue]) => { + const currentStatusType = (sessionStatus?.get(sessionId)?.type ?? 'idle') as SessionStatusType; + const previousStatusType = previousStatusRef.current.get(sessionId); + const becameIdle = + (previousStatusType === 'busy' || previousStatusType === 'retry') + && currentStatusType === 'idle'; + const firstSeenIdle = previousStatusType === undefined && currentStatusType === 'idle'; + + if (queue.length > 0 && (becameIdle || firstSeenIdle)) { + void dispatchSessionQueue(sessionId, queue); + } + + nextStatusMap.set(sessionId, currentStatusType); + }); + + previousStatusRef.current = nextStatusMap; + }, [queuedMessages, sessionStatus]); +} +