feat: queue messages while session is processing, auto-send when idle
This commit is contained in:
parent
f7be8ff31d
commit
1fcd5a7880
1 changed files with 156 additions and 101 deletions
|
|
@ -78,6 +78,11 @@ interface CommandExecutionResult {
|
||||||
hasFileIncludes?: boolean;
|
hasFileIncludes?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QueuedMessage {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
const createFakeSubmitEvent = () => {
|
const createFakeSubmitEvent = () => {
|
||||||
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
|
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
|
||||||
};
|
};
|
||||||
|
|
@ -134,8 +139,6 @@ export function useChatComposerState({
|
||||||
}: UseChatComposerStateArgs) {
|
}: UseChatComposerStateArgs) {
|
||||||
const [input, setInput] = useState(() => {
|
const [input, setInput] = useState(() => {
|
||||||
if (typeof window !== 'undefined' && selectedProject) {
|
if (typeof window !== 'undefined' && selectedProject) {
|
||||||
// Draft inputs are keyed by the DB projectId so per-project drafts
|
|
||||||
// survive display-name changes.
|
|
||||||
return safeLocalStorage.getItem(`draft_input_${selectedProject.projectId}`) || '';
|
return safeLocalStorage.getItem(`draft_input_${selectedProject.projectId}`) || '';
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
|
|
@ -146,6 +149,10 @@ export function useChatComposerState({
|
||||||
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
||||||
const [thinkingMode, setThinkingMode] = useState('none');
|
const [thinkingMode, setThinkingMode] = useState('none');
|
||||||
|
|
||||||
|
// Queue of messages typed while a session is processing
|
||||||
|
const [messageQueue, setMessageQueue] = useState<QueuedMessage[]>([]);
|
||||||
|
const queueIdRef = useRef(0);
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const inputHighlightRef = useRef<HTMLDivElement>(null);
|
const inputHighlightRef = useRef<HTMLDivElement>(null);
|
||||||
const handleSubmitRef = useRef<
|
const handleSubmitRef = useRef<
|
||||||
|
|
@ -258,7 +265,6 @@ export function useChatComposerState({
|
||||||
setInput(commandContent);
|
setInput(commandContent);
|
||||||
inputValueRef.current = commandContent;
|
inputValueRef.current = commandContent;
|
||||||
|
|
||||||
// Defer submit to next tick so the command text is reflected in UI before dispatching.
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (handleSubmitRef.current) {
|
if (handleSubmitRef.current) {
|
||||||
handleSubmitRef.current(createFakeSubmitEvent());
|
handleSubmitRef.current(createFakeSubmitEvent());
|
||||||
|
|
@ -278,8 +284,6 @@ export function useChatComposerState({
|
||||||
const args =
|
const args =
|
||||||
commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : [];
|
commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : [];
|
||||||
|
|
||||||
// The `/api/commands/execute` context sends `projectId` now instead of
|
|
||||||
// a folder-derived project name; the path is still included verbatim.
|
|
||||||
const context = {
|
const context = {
|
||||||
projectPath: selectedProject.fullPath || selectedProject.path,
|
projectPath: selectedProject.fullPath || selectedProject.path,
|
||||||
projectId: selectedProject.projectId,
|
projectId: selectedProject.projectId,
|
||||||
|
|
@ -462,74 +466,16 @@ export function useChatComposerState({
|
||||||
noKeyboard: true,
|
noKeyboard: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
// Core send logic — dispatches a message to the active provider.
|
||||||
async (
|
// Extracted so both handleSubmit and the queue drainer can call it.
|
||||||
event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>,
|
const dispatchMessage = useCallback(
|
||||||
) => {
|
async (rawText: string, uploadedImages: unknown[] = []) => {
|
||||||
event.preventDefault();
|
if (!selectedProject) return;
|
||||||
const currentInput = inputValueRef.current;
|
|
||||||
if (!currentInput.trim() || isLoading || !selectedProject) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Intercept slash commands only when "/" is the first input character.
|
let messageContent = rawText;
|
||||||
const commandInput = currentInput.trimEnd();
|
|
||||||
if (commandInput.startsWith('/')) {
|
|
||||||
const firstSpace = commandInput.indexOf(' ');
|
|
||||||
const commandName = firstSpace > 0 ? commandInput.slice(0, firstSpace) : commandInput;
|
|
||||||
const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName);
|
|
||||||
if (matchedCommand && matchedCommand.type !== 'skill') {
|
|
||||||
executeCommand(matchedCommand, commandInput);
|
|
||||||
setInput('');
|
|
||||||
inputValueRef.current = '';
|
|
||||||
setAttachedImages([]);
|
|
||||||
setUploadingImages(new Map());
|
|
||||||
setImageErrors(new Map());
|
|
||||||
resetCommandMenuState();
|
|
||||||
setIsTextareaExpanded(false);
|
|
||||||
if (textareaRef.current) {
|
|
||||||
textareaRef.current.style.height = 'auto';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let messageContent = currentInput;
|
|
||||||
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
|
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
|
||||||
if (selectedThinkingMode && selectedThinkingMode.prefix) {
|
if (selectedThinkingMode && selectedThinkingMode.prefix) {
|
||||||
messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`;
|
messageContent = `${selectedThinkingMode.prefix}: ${rawText}`;
|
||||||
}
|
|
||||||
|
|
||||||
let uploadedImages: unknown[] = [];
|
|
||||||
if (attachedImages.length > 0) {
|
|
||||||
const formData = new FormData();
|
|
||||||
attachedImages.forEach((file) => {
|
|
||||||
formData.append('images', file);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await authenticatedFetch(`/api/projects/${selectedProject.projectId}/upload-images`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to upload images');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
uploadedImages = result.images;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
console.error('Image upload failed:', error);
|
|
||||||
addMessage({
|
|
||||||
type: 'error',
|
|
||||||
content: `Failed to upload images: ${message}`,
|
|
||||||
timestamp: new Date(),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveSessionId =
|
const effectiveSessionId =
|
||||||
|
|
@ -537,30 +483,22 @@ export function useChatComposerState({
|
||||||
|
|
||||||
const userMessage: ChatMessage = {
|
const userMessage: ChatMessage = {
|
||||||
type: 'user',
|
type: 'user',
|
||||||
content: currentInput,
|
content: rawText,
|
||||||
images: uploadedImages as any,
|
images: uploadedImages as any,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
addMessage(userMessage);
|
addMessage(userMessage);
|
||||||
setIsLoading(true); // Processing banner starts
|
setIsLoading(true);
|
||||||
setCanAbortSession(true);
|
setCanAbortSession(true);
|
||||||
setClaudeStatus({
|
setClaudeStatus({ text: 'Processing', tokens: 0, can_interrupt: true });
|
||||||
text: 'Processing',
|
|
||||||
tokens: 0,
|
|
||||||
can_interrupt: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsUserScrolledUp(false);
|
setIsUserScrolledUp(false);
|
||||||
setTimeout(() => scrollToBottom(), 100);
|
setTimeout(() => scrollToBottom(), 100);
|
||||||
|
|
||||||
if (!effectiveSessionId && !selectedSession?.id) {
|
if (!effectiveSessionId && !selectedSession?.id) {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// Reset stale pending IDs from previous interrupted runs before creating a new one.
|
|
||||||
sessionStorage.removeItem('pendingSessionId');
|
sessionStorage.removeItem('pendingSessionId');
|
||||||
}
|
}
|
||||||
// For new sessions we intentionally keep this as `null` until the backend
|
|
||||||
// emits `session_created` with the canonical provider session id.
|
|
||||||
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
|
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
|
||||||
}
|
}
|
||||||
if (effectiveSessionId) {
|
if (effectiveSessionId) {
|
||||||
|
|
@ -595,7 +533,7 @@ export function useChatComposerState({
|
||||||
|
|
||||||
const toolsSettings = getToolsSettings();
|
const toolsSettings = getToolsSettings();
|
||||||
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
|
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
|
||||||
const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput);
|
const sessionSummary = getNotificationSessionSummary(selectedSession, rawText);
|
||||||
|
|
||||||
if (provider === 'cursor') {
|
if (provider === 'cursor') {
|
||||||
sendMessage({
|
sendMessage({
|
||||||
|
|
@ -661,6 +599,118 @@ export function useChatComposerState({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
addMessage,
|
||||||
|
claudeModel,
|
||||||
|
codexModel,
|
||||||
|
currentSessionId,
|
||||||
|
cursorModel,
|
||||||
|
geminiModel,
|
||||||
|
onSessionActive,
|
||||||
|
onSessionProcessing,
|
||||||
|
pendingViewSessionRef,
|
||||||
|
permissionMode,
|
||||||
|
provider,
|
||||||
|
scrollToBottom,
|
||||||
|
selectedProject,
|
||||||
|
selectedSession,
|
||||||
|
sendMessage,
|
||||||
|
setCanAbortSession,
|
||||||
|
setClaudeStatus,
|
||||||
|
setIsLoading,
|
||||||
|
setIsUserScrolledUp,
|
||||||
|
thinkingMode,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (
|
||||||
|
event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>,
|
||||||
|
) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const currentInput = inputValueRef.current;
|
||||||
|
if (!currentInput.trim() || !selectedProject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intercept slash commands only when "/" is the first input character.
|
||||||
|
const commandInput = currentInput.trimEnd();
|
||||||
|
if (commandInput.startsWith('/')) {
|
||||||
|
const firstSpace = commandInput.indexOf(' ');
|
||||||
|
const commandName = firstSpace > 0 ? commandInput.slice(0, firstSpace) : commandInput;
|
||||||
|
const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName);
|
||||||
|
if (matchedCommand && matchedCommand.type !== 'skill') {
|
||||||
|
executeCommand(matchedCommand, commandInput);
|
||||||
|
setInput('');
|
||||||
|
inputValueRef.current = '';
|
||||||
|
setAttachedImages([]);
|
||||||
|
setUploadingImages(new Map());
|
||||||
|
setImageErrors(new Map());
|
||||||
|
resetCommandMenuState();
|
||||||
|
setIsTextareaExpanded(false);
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.style.height = 'auto';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a session is currently processing, queue the message instead of blocking.
|
||||||
|
// Slash commands are NOT queued (handled above). Images are not queued (must send now).
|
||||||
|
if (isLoading && attachedImages.length === 0) {
|
||||||
|
queueIdRef.current += 1;
|
||||||
|
const id = `q-${queueIdRef.current}`;
|
||||||
|
setMessageQueue((prev) => [...prev, { id, text: currentInput }]);
|
||||||
|
setInput('');
|
||||||
|
inputValueRef.current = '';
|
||||||
|
resetCommandMenuState();
|
||||||
|
setIsTextareaExpanded(false);
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.style.height = 'auto';
|
||||||
|
}
|
||||||
|
safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block only if loading AND there are images (can't queue image uploads safely)
|
||||||
|
if (isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let uploadedImages: unknown[] = [];
|
||||||
|
if (attachedImages.length > 0) {
|
||||||
|
const formData = new FormData();
|
||||||
|
attachedImages.forEach((file) => {
|
||||||
|
formData.append('images', file);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch(`/api/projects/${selectedProject.projectId}/upload-images`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to upload images');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
uploadedImages = result.images;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error('Image upload failed:', error);
|
||||||
|
addMessage({
|
||||||
|
type: 'error',
|
||||||
|
content: `Failed to upload images: ${message}`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await dispatchMessage(currentInput, uploadedImages);
|
||||||
|
|
||||||
setInput('');
|
setInput('');
|
||||||
inputValueRef.current = '';
|
inputValueRef.current = '';
|
||||||
|
|
@ -678,34 +728,35 @@ export function useChatComposerState({
|
||||||
safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`);
|
safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
selectedSession,
|
|
||||||
attachedImages,
|
attachedImages,
|
||||||
claudeModel,
|
dispatchMessage,
|
||||||
codexModel,
|
|
||||||
currentSessionId,
|
|
||||||
cursorModel,
|
|
||||||
executeCommand,
|
executeCommand,
|
||||||
geminiModel,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
onSessionActive,
|
|
||||||
onSessionProcessing,
|
|
||||||
pendingViewSessionRef,
|
|
||||||
permissionMode,
|
|
||||||
provider,
|
|
||||||
resetCommandMenuState,
|
resetCommandMenuState,
|
||||||
scrollToBottom,
|
|
||||||
selectedProject,
|
selectedProject,
|
||||||
sendMessage,
|
|
||||||
setCanAbortSession,
|
|
||||||
addMessage,
|
addMessage,
|
||||||
setClaudeStatus,
|
|
||||||
setIsLoading,
|
|
||||||
setIsUserScrolledUp,
|
|
||||||
slashCommands,
|
slashCommands,
|
||||||
thinkingMode,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Drain the queue: when the session goes idle and there are queued messages,
|
||||||
|
// send the next one automatically.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading || messageQueue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [next, ...rest] = messageQueue;
|
||||||
|
setMessageQueue(rest);
|
||||||
|
// Fire after current render so isLoading flips correctly
|
||||||
|
setTimeout(() => {
|
||||||
|
dispatchMessage(next.text, []);
|
||||||
|
}, 50);
|
||||||
|
}, [isLoading, messageQueue, dispatchMessage]);
|
||||||
|
|
||||||
|
const removeQueuedMessage = useCallback((id: string) => {
|
||||||
|
setMessageQueue((prev) => prev.filter((m) => m.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleSubmitRef.current = handleSubmit;
|
handleSubmitRef.current = handleSubmit;
|
||||||
}, [handleSubmit]);
|
}, [handleSubmit]);
|
||||||
|
|
@ -741,7 +792,6 @@ export function useChatComposerState({
|
||||||
if (!textareaRef.current) {
|
if (!textareaRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Re-run when input changes so restored drafts get the same autosize behavior as typed text.
|
|
||||||
textareaRef.current.style.height = 'auto';
|
textareaRef.current.style.height = 'auto';
|
||||||
textareaRef.current.style.height = `${Math.max(22, textareaRef.current.scrollHeight)}px`;
|
textareaRef.current.style.height = `${Math.max(22, textareaRef.current.scrollHeight)}px`;
|
||||||
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
|
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
|
||||||
|
|
@ -856,6 +906,9 @@ export function useChatComposerState({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear any queued messages when the user aborts
|
||||||
|
setMessageQueue([]);
|
||||||
|
|
||||||
const pendingSessionId =
|
const pendingSessionId =
|
||||||
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
|
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
|
||||||
const cursorSessionId =
|
const cursorSessionId =
|
||||||
|
|
@ -980,5 +1033,7 @@ export function useChatComposerState({
|
||||||
handleGrantToolPermission,
|
handleGrantToolPermission,
|
||||||
handleInputFocusChange,
|
handleInputFocusChange,
|
||||||
isInputFocused,
|
isInputFocused,
|
||||||
|
messageQueue,
|
||||||
|
removeQueuedMessage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue