Fix queued send for inactive sessions (#513)
This commit is contained in:
committed by
GitHub
parent
9a2af4c4d9
commit
25ee06dae9
@@ -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) => {
|
||||
|
||||
@@ -136,7 +136,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<HTMLTextAreaElement>) => {
|
||||
if (!isMobile) {
|
||||
@@ -689,45 +686,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ 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<HTMLTextAreaElement>) => {
|
||||
// Early return during IME composition to prevent interference with autocomplete.
|
||||
// Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown.
|
||||
|
||||
@@ -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<Set<string>>(new Set());
|
||||
const previousStatusRef = React.useRef<Map<string, SessionStatusType>>(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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user