From 4004503ff7f26a40cae4cf51fe7f44abb9b70430 Mon Sep 17 00:00:00 2001 From: Jeremy Holstein Date: Tue, 27 Jan 2026 12:09:15 -0500 Subject: [PATCH 1/2] show expired questions --- src/renderer/features/agents/atoms/index.ts | 5 + .../features/agents/lib/ipc-chat-transport.ts | 28 ++- .../features/agents/main/active-chat.tsx | 187 ++++++++++++------ .../agents/ui/agent-user-question.tsx | 2 + 4 files changed, 163 insertions(+), 59 deletions(-) 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..e24327f1 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,58 @@ 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]) // 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 + const answerLines = Object.entries(answers) + .map(([question, answer]) => `${question}: ${answer}`) + .join("\n") + + clearPendingQuestionCallback() + + shouldAutoScrollRef.current = true + await sendMessageRef.current({ + role: "user", + parts: [{ type: "text", text: answerLines }], + }) + } 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], ) // 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 +2706,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 +2714,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 +2732,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 +2743,74 @@ 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 + const answerLines = Object.entries(formattedAnswers) + .map(([question, answer]) => `${question}: ${answer}`) + .join("\n") - // 5. Stop stream if currently streaming - if (isStreamingRef.current) { - agentChatStore.setManuallyAborted(subChatId, true) - await stopRef.current() - await new Promise((resolve) => setTimeout(resolve, 100)) - } + clearPendingQuestionCallback() - // 6. Clear input - editorRef.current?.clear() - if (parentChatId) { - clearSubChatDraft(parentChatId, subChatId) - } + // Clear input + editorRef.current?.clear() + if (parentChatId) { + clearSubChatDraft(parentChatId, subChatId) + } - // 7. Send custom text as a new user message - shouldAutoScrollRef.current = true - await sendMessageRef.current({ - role: "user", - parts: [{ type: "text", text: customText }], - }) + // Send combined answers + custom text as a message + shouldAutoScrollRef.current = true + await sendMessageRef.current({ + role: "user", + parts: [{ type: "text", text: answerLines }], + }) + } else { + // Live: use existing tool approval flow + // 4. Submit tool response with all answers + 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)) + } + + // 6. Clear input + editorRef.current?.clear() + if (parentChatId) { + clearSubChatDraft(parentChatId, subChatId) + } + + // 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, subChatId, parentChatId], ) // 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 +3103,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 +3153,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 +3322,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 +3572,7 @@ const ChatViewInner = memo(function ChatViewInner({ clearPastedTexts, teamId, addToQueue, + setExpiredQuestionsMap, ]) // Queue handlers for sending queued messages @@ -4016,24 +4093,24 @@ 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 && (
)} {/* Stacked cards container - queue + status */} - {!pendingQuestions && + {!displayQuestions && (queue.length > 0 || changedFilesForSubChat.length > 0) && (
@@ -4109,7 +4186,7 @@ const ChatViewInner = memo(function ChatViewInner({ 0 || changedFilesForSubChat.length > 0)} + hasStackedCards={!displayQuestions && (queue.length > 0 || changedFilesForSubChat.length > 0)} subChatId={subChatId} isActive={isActive} /> diff --git a/src/renderer/features/agents/ui/agent-user-question.tsx b/src/renderer/features/agents/ui/agent-user-question.tsx index 8120579b..860d8de6 100644 --- a/src/renderer/features/agents/ui/agent-user-question.tsx +++ b/src/renderer/features/agents/ui/agent-user-question.tsx @@ -11,6 +11,7 @@ interface AgentUserQuestionProps { onAnswer: (answers: Record) => void onSkip: () => void hasCustomText?: boolean + isExpired?: boolean } export interface AgentUserQuestionHandle { @@ -23,6 +24,7 @@ export const AgentUserQuestion = memo(forwardRef Date: Tue, 27 Jan 2026 14:13:13 -0500 Subject: [PATCH 2/2] refactor: extract shared helpers for expired question handlers --- .../features/agents/main/active-chat.tsx | 76 ++++++++----------- .../agents/ui/agent-user-question.tsx | 2 - 2 files changed, 32 insertions(+), 46 deletions(-) diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index e24327f1..4d8a509c 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -2649,6 +2649,30 @@ const ChatViewInner = memo(function ChatViewInner({ }) }, [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) => { @@ -2656,17 +2680,8 @@ const ChatViewInner = memo(function ChatViewInner({ if (isQuestionExpired) { // Question timed out - send answers as a normal user message - const answerLines = Object.entries(answers) - .map(([question, answer]) => `${question}: ${answer}`) - .join("\n") - clearPendingQuestionCallback() - - shouldAutoScrollRef.current = true - await sendMessageRef.current({ - role: "user", - parts: [{ type: "text", text: answerLines }], - }) + await sendUserMessage(formatAnswersAsText(answers)) } else { // Question is still live - use tool approval path await trpcClient.claude.respondToolApproval.mutate({ @@ -2677,7 +2692,7 @@ const ChatViewInner = memo(function ChatViewInner({ clearPendingQuestionCallback() } }, - [displayQuestions, isQuestionExpired, clearPendingQuestionCallback], + [displayQuestions, isQuestionExpired, clearPendingQuestionCallback, sendUserMessage, formatAnswersAsText], ) // Handle skipping questions @@ -2745,27 +2760,11 @@ const ChatViewInner = memo(function ChatViewInner({ if (isQuestionExpired) { // Expired: format everything as a user message - const answerLines = Object.entries(formattedAnswers) - .map(([question, answer]) => `${question}: ${answer}`) - .join("\n") - clearPendingQuestionCallback() - - // Clear input - editorRef.current?.clear() - if (parentChatId) { - clearSubChatDraft(parentChatId, subChatId) - } - - // Send combined answers + custom text as a message - shouldAutoScrollRef.current = true - await sendMessageRef.current({ - role: "user", - parts: [{ type: "text", text: answerLines }], - }) + clearInputAndDraft() + await sendUserMessage(formatAnswersAsText(formattedAnswers)) } else { // Live: use existing tool approval flow - // 4. Submit tool response with all answers await trpcClient.claude.respondToolApproval.mutate({ toolUseId: displayQuestions.toolUseId, approved: true, @@ -2776,31 +2775,21 @@ const ChatViewInner = memo(function ChatViewInner({ }) clearPendingQuestionCallback() - // 5. Stop stream if currently streaming + // 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) - } - - // 7. Send custom text as a new user message - shouldAutoScrollRef.current = true - await sendMessageRef.current({ - role: "user", - parts: [{ type: "text", text: customText }], - }) + clearInputAndDraft() + await sendUserMessage(customText) } } finally { isSubmittingQuestionAnswerRef.current = false } }, - [displayQuestions, isQuestionExpired, clearPendingQuestionCallback, subChatId, parentChatId], + [displayQuestions, isQuestionExpired, clearPendingQuestionCallback, clearInputAndDraft, sendUserMessage, formatAnswersAsText, subChatId], ) // Memoize the callback to prevent ChatInputArea re-renders @@ -4103,7 +4092,6 @@ const ChatViewInner = memo(function ChatViewInner({ onAnswer={handleQuestionsAnswer} onSkip={handleQuestionsSkip} hasCustomText={inputHasContent} - isExpired={isQuestionExpired} />
diff --git a/src/renderer/features/agents/ui/agent-user-question.tsx b/src/renderer/features/agents/ui/agent-user-question.tsx index 860d8de6..8120579b 100644 --- a/src/renderer/features/agents/ui/agent-user-question.tsx +++ b/src/renderer/features/agents/ui/agent-user-question.tsx @@ -11,7 +11,6 @@ interface AgentUserQuestionProps { onAnswer: (answers: Record) => void onSkip: () => void hasCustomText?: boolean - isExpired?: boolean } export interface AgentUserQuestionHandle { @@ -24,7 +23,6 @@ export const AgentUserQuestion = memo(forwardRef