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;
|
||||
}
|
||||
|
||||
interface QueuedMessage {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const createFakeSubmitEvent = () => {
|
||||
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
|
||||
};
|
||||
|
|
@ -134,8 +139,6 @@ export function useChatComposerState({
|
|||
}: UseChatComposerStateArgs) {
|
||||
const [input, setInput] = useState(() => {
|
||||
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 '';
|
||||
|
|
@ -146,6 +149,10 @@ export function useChatComposerState({
|
|||
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
||||
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 inputHighlightRef = useRef<HTMLDivElement>(null);
|
||||
const handleSubmitRef = useRef<
|
||||
|
|
@ -258,7 +265,6 @@ export function useChatComposerState({
|
|||
setInput(commandContent);
|
||||
inputValueRef.current = commandContent;
|
||||
|
||||
// Defer submit to next tick so the command text is reflected in UI before dispatching.
|
||||
setTimeout(() => {
|
||||
if (handleSubmitRef.current) {
|
||||
handleSubmitRef.current(createFakeSubmitEvent());
|
||||
|
|
@ -278,8 +284,6 @@ export function useChatComposerState({
|
|||
const args =
|
||||
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 = {
|
||||
projectPath: selectedProject.fullPath || selectedProject.path,
|
||||
projectId: selectedProject.projectId,
|
||||
|
|
@ -462,74 +466,16 @@ export function useChatComposerState({
|
|||
noKeyboard: true,
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (
|
||||
event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
const currentInput = inputValueRef.current;
|
||||
if (!currentInput.trim() || isLoading || !selectedProject) {
|
||||
return;
|
||||
}
|
||||
// Core send logic — dispatches a message to the active provider.
|
||||
// Extracted so both handleSubmit and the queue drainer can call it.
|
||||
const dispatchMessage = useCallback(
|
||||
async (rawText: string, uploadedImages: unknown[] = []) => {
|
||||
if (!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;
|
||||
}
|
||||
}
|
||||
|
||||
let messageContent = currentInput;
|
||||
let messageContent = rawText;
|
||||
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
|
||||
if (selectedThinkingMode && selectedThinkingMode.prefix) {
|
||||
messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
messageContent = `${selectedThinkingMode.prefix}: ${rawText}`;
|
||||
}
|
||||
|
||||
const effectiveSessionId =
|
||||
|
|
@ -537,30 +483,22 @@ export function useChatComposerState({
|
|||
|
||||
const userMessage: ChatMessage = {
|
||||
type: 'user',
|
||||
content: currentInput,
|
||||
content: rawText,
|
||||
images: uploadedImages as any,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
addMessage(userMessage);
|
||||
setIsLoading(true); // Processing banner starts
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
setClaudeStatus({
|
||||
text: 'Processing',
|
||||
tokens: 0,
|
||||
can_interrupt: true,
|
||||
});
|
||||
|
||||
setClaudeStatus({ text: 'Processing', tokens: 0, can_interrupt: true });
|
||||
setIsUserScrolledUp(false);
|
||||
setTimeout(() => scrollToBottom(), 100);
|
||||
|
||||
if (!effectiveSessionId && !selectedSession?.id) {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Reset stale pending IDs from previous interrupted runs before creating a new one.
|
||||
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() };
|
||||
}
|
||||
if (effectiveSessionId) {
|
||||
|
|
@ -595,7 +533,7 @@ export function useChatComposerState({
|
|||
|
||||
const toolsSettings = getToolsSettings();
|
||||
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
|
||||
const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput);
|
||||
const sessionSummary = getNotificationSessionSummary(selectedSession, rawText);
|
||||
|
||||
if (provider === 'cursor') {
|
||||
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('');
|
||||
inputValueRef.current = '';
|
||||
|
|
@ -678,34 +728,35 @@ export function useChatComposerState({
|
|||
safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`);
|
||||
},
|
||||
[
|
||||
selectedSession,
|
||||
attachedImages,
|
||||
claudeModel,
|
||||
codexModel,
|
||||
currentSessionId,
|
||||
cursorModel,
|
||||
dispatchMessage,
|
||||
executeCommand,
|
||||
geminiModel,
|
||||
isLoading,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
pendingViewSessionRef,
|
||||
permissionMode,
|
||||
provider,
|
||||
resetCommandMenuState,
|
||||
scrollToBottom,
|
||||
selectedProject,
|
||||
sendMessage,
|
||||
setCanAbortSession,
|
||||
addMessage,
|
||||
setClaudeStatus,
|
||||
setIsLoading,
|
||||
setIsUserScrolledUp,
|
||||
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(() => {
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
}, [handleSubmit]);
|
||||
|
|
@ -741,7 +792,6 @@ export function useChatComposerState({
|
|||
if (!textareaRef.current) {
|
||||
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 = `${Math.max(22, textareaRef.current.scrollHeight)}px`;
|
||||
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
|
||||
|
|
@ -856,6 +906,9 @@ export function useChatComposerState({
|
|||
return;
|
||||
}
|
||||
|
||||
// Clear any queued messages when the user aborts
|
||||
setMessageQueue([]);
|
||||
|
||||
const pendingSessionId =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
|
||||
const cursorSessionId =
|
||||
|
|
@ -980,5 +1033,7 @@ export function useChatComposerState({
|
|||
handleGrantToolPermission,
|
||||
handleInputFocusChange,
|
||||
isInputFocused,
|
||||
messageQueue,
|
||||
removeQueuedMessage,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue