diff --git a/src/renderer/features/agents/atoms/index.ts b/src/renderer/features/agents/atoms/index.ts index cb858f6b..8751abec 100644 --- a/src/renderer/features/agents/atoms/index.ts +++ b/src/renderer/features/agents/atoms/index.ts @@ -635,6 +635,11 @@ export const pendingUserQuestionsAtom = atom>(n // Legacy type alias for backwards compatibility export type PendingUserQuestions = PendingUserQuestion +// Expired user questions - questions that timed out but should still be answerable +// When answered, responses are sent as normal user messages instead of tool approvals +// Map +export const expiredUserQuestionsAtom = atom>(new Map()) + // Track sub-chats with pending plan approval (plan ready but not yet implemented) // Map - allows filtering by workspace export const pendingPlanApprovalsAtom = atom>(new Map()) diff --git a/src/renderer/features/agents/lib/ipc-chat-transport.ts b/src/renderer/features/agents/lib/ipc-chat-transport.ts index 10973a4e..c52af6b5 100644 --- a/src/renderer/features/agents/lib/ipc-chat-transport.ts +++ b/src/renderer/features/agents/lib/ipc-chat-transport.ts @@ -18,6 +18,7 @@ import { trpcClient } from "../../../lib/trpc" import { askUserQuestionResultsAtom, compactingSubChatsAtom, + expiredUserQuestionsAtom, lastSelectedModelIdAtom, MODEL_ID_MAP, pendingAuthRetryMessageAtom, @@ -226,16 +227,31 @@ export class IPCChatTransport implements ChatTransport { questions: chunk.questions, }) appStore.set(pendingUserQuestionsAtom, newMap) + + // Clear any expired question (new question replaces it) + const currentExpired = appStore.get(expiredUserQuestionsAtom) + if (currentExpired.has(this.config.subChatId)) { + const newExpiredMap = new Map(currentExpired) + newExpiredMap.delete(this.config.subChatId) + appStore.set(expiredUserQuestionsAtom, newExpiredMap) + } } - // Handle AskUserQuestion timeout - clear pending question immediately + // Handle AskUserQuestion timeout - move to expired (keep UI visible) if (chunk.type === "ask-user-question-timeout") { const currentMap = appStore.get(pendingUserQuestionsAtom) const pending = currentMap.get(this.config.subChatId) if (pending && pending.toolUseId === chunk.toolUseId) { - const newMap = new Map(currentMap) - newMap.delete(this.config.subChatId) - appStore.set(pendingUserQuestionsAtom, newMap) + // Remove from pending + const newPendingMap = new Map(currentMap) + newPendingMap.delete(this.config.subChatId) + appStore.set(pendingUserQuestionsAtom, newPendingMap) + + // Move to expired (so UI keeps showing the question) + const currentExpired = appStore.get(expiredUserQuestionsAtom) + const newExpiredMap = new Map(currentExpired) + newExpiredMap.set(this.config.subChatId, pending) + appStore.set(expiredUserQuestionsAtom, newExpiredMap) } } @@ -297,6 +313,10 @@ export class IPCChatTransport implements ChatTransport { newMap.delete(this.config.subChatId) appStore.set(pendingUserQuestionsAtom, newMap) } + // NOTE: Do NOT clear expired questions here. After a timeout, + // the agent continues and emits new chunks — that's expected. + // Expired questions should persist until the user answers, + // dismisses, or sends a new message. } // Handle authentication errors - show Claude login modal diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index 525d13ff..4d8a509c 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -118,6 +118,7 @@ import { pendingPrMessageAtom, pendingReviewMessageAtom, pendingUserQuestionsAtom, + expiredUserQuestionsAtom, planSidebarOpenAtomFamily, QUESTIONS_SKIPPED_MESSAGE, selectedAgentChatIdAtom, @@ -2483,6 +2484,16 @@ const ChatViewInner = memo(function ChatViewInner({ // Get pending questions for this specific subChat const pendingQuestions = pendingQuestionsMap.get(subChatId) ?? null + // Expired user questions (timed out but still answerable as normal messages) + const [expiredQuestionsMap, setExpiredQuestionsMap] = useAtom( + expiredUserQuestionsAtom, + ) + const expiredQuestions = expiredQuestionsMap.get(subChatId) ?? null + + // Unified display questions: prefer pending (live), fall back to expired + const displayQuestions = pendingQuestions ?? expiredQuestions + const isQuestionExpired = !pendingQuestions && !!expiredQuestions + // Track whether chat input has content (for custom text with questions) const [inputHasContent, setInputHasContent] = useState(false) @@ -2618,7 +2629,7 @@ const ChatViewInner = memo(function ChatViewInner({ } }, [subChatId, lastAssistantMessage, isStreaming, pendingQuestions, setPendingQuestionsMap]) - // Helper to clear pending question for this subChat (used in callbacks) + // Helper to clear pending and expired questions for this subChat (used in callbacks) const clearPendingQuestionCallback = useCallback(() => { setPendingQuestionsMap((current) => { if (current.has(subChatId)) { @@ -2628,26 +2639,73 @@ const ChatViewInner = memo(function ChatViewInner({ } return current }) - }, [subChatId, setPendingQuestionsMap]) + setExpiredQuestionsMap((current) => { + if (current.has(subChatId)) { + const newMap = new Map(current) + newMap.delete(subChatId) + return newMap + } + return current + }) + }, [subChatId, setPendingQuestionsMap, setExpiredQuestionsMap]) + + // Shared helpers for question answer handlers + const formatAnswersAsText = useCallback( + (answers: Record): string => + Object.entries(answers) + .map(([question, answer]) => `${question}: ${answer}`) + .join("\n"), + [], + ) + + const clearInputAndDraft = useCallback(() => { + editorRef.current?.clear() + if (parentChatId) { + clearSubChatDraft(parentChatId, subChatId) + } + }, [parentChatId, subChatId]) + + const sendUserMessage = useCallback(async (text: string) => { + shouldAutoScrollRef.current = true + await sendMessageRef.current({ + role: "user", + parts: [{ type: "text", text }], + }) + }, []) // Handle answering questions const handleQuestionsAnswer = useCallback( async (answers: Record) => { - if (!pendingQuestions) return - await trpcClient.claude.respondToolApproval.mutate({ - toolUseId: pendingQuestions.toolUseId, - approved: true, - updatedInput: { questions: pendingQuestions.questions, answers }, - }) - clearPendingQuestionCallback() + if (!displayQuestions) return + + if (isQuestionExpired) { + // Question timed out - send answers as a normal user message + clearPendingQuestionCallback() + await sendUserMessage(formatAnswersAsText(answers)) + } else { + // Question is still live - use tool approval path + await trpcClient.claude.respondToolApproval.mutate({ + toolUseId: displayQuestions.toolUseId, + approved: true, + updatedInput: { questions: displayQuestions.questions, answers }, + }) + clearPendingQuestionCallback() + } }, - [pendingQuestions, clearPendingQuestionCallback], + [displayQuestions, isQuestionExpired, clearPendingQuestionCallback, sendUserMessage, formatAnswersAsText], ) // Handle skipping questions const handleQuestionsSkip = useCallback(async () => { - if (!pendingQuestions) return - const toolUseId = pendingQuestions.toolUseId + if (!displayQuestions) return + + if (isQuestionExpired) { + // Expired question - just clear the UI, no backend call needed + clearPendingQuestionCallback() + return + } + + const toolUseId = displayQuestions.toolUseId // Clear UI immediately - don't wait for backend // This ensures dialog closes even if stream was already aborted @@ -2663,7 +2721,7 @@ const ChatViewInner = memo(function ChatViewInner({ } catch { // Stream likely already aborted - ignore } - }, [pendingQuestions, clearPendingQuestionCallback]) + }, [displayQuestions, isQuestionExpired, clearPendingQuestionCallback]) // Ref to prevent double submit of question answer const isSubmittingQuestionAnswerRef = useRef(false) @@ -2671,7 +2729,7 @@ const ChatViewInner = memo(function ChatViewInner({ // Handle answering questions with custom text from input (called on Enter in input) const handleSubmitWithQuestionAnswer = useCallback( async () => { - if (!pendingQuestions) return + if (!displayQuestions) return if (isSubmittingQuestionAnswerRef.current) return isSubmittingQuestionAnswerRef.current = true @@ -2689,7 +2747,7 @@ const ChatViewInner = memo(function ChatViewInner({ // 3. Add custom text to the last question as "Other" const lastQuestion = - pendingQuestions.questions[pendingQuestions.questions.length - 1] + displayQuestions.questions[displayQuestions.questions.length - 1] if (lastQuestion) { const existingAnswer = formattedAnswers[lastQuestion.question] if (existingAnswer) { @@ -2700,51 +2758,48 @@ const ChatViewInner = memo(function ChatViewInner({ } } - // 4. Submit tool response with all answers - await trpcClient.claude.respondToolApproval.mutate({ - toolUseId: pendingQuestions.toolUseId, - approved: true, - updatedInput: { - questions: pendingQuestions.questions, - answers: formattedAnswers, - }, - }) - clearPendingQuestionCallback() + if (isQuestionExpired) { + // Expired: format everything as a user message + clearPendingQuestionCallback() + clearInputAndDraft() + await sendUserMessage(formatAnswersAsText(formattedAnswers)) + } else { + // Live: use existing tool approval flow + await trpcClient.claude.respondToolApproval.mutate({ + toolUseId: displayQuestions.toolUseId, + approved: true, + updatedInput: { + questions: displayQuestions.questions, + answers: formattedAnswers, + }, + }) + clearPendingQuestionCallback() - // 5. Stop stream if currently streaming - if (isStreamingRef.current) { - agentChatStore.setManuallyAborted(subChatId, true) - await stopRef.current() - await new Promise((resolve) => setTimeout(resolve, 100)) - } + // Stop stream if currently streaming + if (isStreamingRef.current) { + agentChatStore.setManuallyAborted(subChatId, true) + await stopRef.current() + await new Promise((resolve) => setTimeout(resolve, 100)) + } - // 6. Clear input - editorRef.current?.clear() - if (parentChatId) { - clearSubChatDraft(parentChatId, subChatId) + clearInputAndDraft() + await sendUserMessage(customText) } - - // 7. Send custom text as a new user message - shouldAutoScrollRef.current = true - await sendMessageRef.current({ - role: "user", - parts: [{ type: "text", text: customText }], - }) } finally { isSubmittingQuestionAnswerRef.current = false } }, - [pendingQuestions, clearPendingQuestionCallback, subChatId, parentChatId], + [displayQuestions, isQuestionExpired, clearPendingQuestionCallback, clearInputAndDraft, sendUserMessage, formatAnswersAsText, subChatId], ) // Memoize the callback to prevent ChatInputArea re-renders - // Only provide callback when there's a pending question for this subChat + // Only provide callback when there's a pending or expired question for this subChat const submitWithQuestionAnswerCallback = useMemo( () => - pendingQuestions + displayQuestions ? handleSubmitWithQuestionAnswer : undefined, - [pendingQuestions, handleSubmitWithQuestionAnswer], + [displayQuestions, handleSubmitWithQuestionAnswer], ) // Watch for pending auth retry message (after successful OAuth flow) @@ -3037,8 +3092,8 @@ const ChatViewInner = memo(function ChatViewInner({ ) if (!isInsideOverlay && !hasOpenDialog) { - // If there are pending questions for this chat, skip them instead of stopping stream - if (pendingQuestions) { + // If there are pending/expired questions for this chat, skip/dismiss them instead of stopping stream + if (displayQuestions) { shouldSkipQuestions = true } else { shouldStop = true @@ -3087,7 +3142,7 @@ const ChatViewInner = memo(function ChatViewInner({ window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) - }, [isActive, isStreaming, stop, subChatId, pendingQuestions, handleQuestionsSkip]) + }, [isActive, isStreaming, stop, subChatId, displayQuestions, handleQuestionsSkip]) // Keyboard shortcut: Enter to focus input when not already focused useFocusInputOnEnter(editorRef) @@ -3256,6 +3311,16 @@ const ChatViewInner = memo(function ChatViewInner({ return } + // Clear any expired questions when user sends a new message + setExpiredQuestionsMap((current) => { + if (current.has(subChatId)) { + const newMap = new Map(current) + newMap.delete(subChatId) + return newMap + } + return current + }) + // Get value from uncontrolled editor const inputValue = editorRef.current?.getValue() || "" const hasText = inputValue.trim().length > 0 @@ -3496,6 +3561,7 @@ const ChatViewInner = memo(function ChatViewInner({ clearPastedTexts, teamId, addToQueue, + setExpiredQuestionsMap, ]) // Queue handlers for sending queued messages @@ -4016,14 +4082,13 @@ const ChatViewInner = memo(function ChatViewInner({ - {/* User questions panel - shows when AskUserQuestion tool is called */} - {/* Only show if the pending question belongs to THIS sub-chat */} - {pendingQuestions && ( + {/* User questions panel - shows for both live (pending) and expired (timed out) questions */} + {displayQuestions && (
0 || changedFilesForSubChat.length > 0) && (
@@ -4109,7 +4174,7 @@ const ChatViewInner = memo(function ChatViewInner({ 0 || changedFilesForSubChat.length > 0)} + hasStackedCards={!displayQuestions && (queue.length > 0 || changedFilesForSubChat.length > 0)} subChatId={subChatId} isActive={isActive} />