Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/renderer/features/agents/atoms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,11 @@ export const pendingUserQuestionsAtom = atom<Map<string, PendingUserQuestion>>(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<subChatId, PendingUserQuestion>
export const expiredUserQuestionsAtom = atom<Map<string, PendingUserQuestion>>(new Map())

// Track sub-chats with pending plan approval (plan ready but not yet implemented)
// Map<subChatId, parentChatId> - allows filtering by workspace
export const pendingPlanApprovalsAtom = atom<Map<string, string>>(new Map())
Expand Down
28 changes: 24 additions & 4 deletions src/renderer/features/agents/lib/ipc-chat-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { trpcClient } from "../../../lib/trpc"
import {
askUserQuestionResultsAtom,
compactingSubChatsAtom,
expiredUserQuestionsAtom,
lastSelectedModelIdAtom,
MODEL_ID_MAP,
pendingAuthRetryMessageAtom,
Expand Down Expand Up @@ -226,16 +227,31 @@ export class IPCChatTransport implements ChatTransport<UIMessage> {
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)
}
}

Expand Down Expand Up @@ -297,6 +313,10 @@ export class IPCChatTransport implements ChatTransport<UIMessage> {
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
Expand Down
175 changes: 120 additions & 55 deletions src/renderer/features/agents/main/active-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ import {
pendingPrMessageAtom,
pendingReviewMessageAtom,
pendingUserQuestionsAtom,
expiredUserQuestionsAtom,
planSidebarOpenAtomFamily,
QUESTIONS_SKIPPED_MESSAGE,
selectedAgentChatIdAtom,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)) {
Expand All @@ -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, string>): 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<string, string>) => {
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
Expand All @@ -2663,15 +2721,15 @@ 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)

// 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

Expand All @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -3496,6 +3561,7 @@ const ChatViewInner = memo(function ChatViewInner({
clearPastedTexts,
teamId,
addToQueue,
setExpiredQuestionsMap,
])

// Queue handlers for sending queued messages
Expand Down Expand Up @@ -4016,14 +4082,13 @@ const ChatViewInner = memo(function ChatViewInner({
</div>
</div>

{/* 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 && (
<div className="px-4 relative z-20">
<div className="w-full px-2 max-w-2xl mx-auto">
<AgentUserQuestion
ref={questionRef}
pendingQuestions={pendingQuestions}
pendingQuestions={displayQuestions}
onAnswer={handleQuestionsAnswer}
onSkip={handleQuestionsSkip}
hasCustomText={inputHasContent}
Expand All @@ -4033,7 +4098,7 @@ const ChatViewInner = memo(function ChatViewInner({
)}

{/* Stacked cards container - queue + status */}
{!pendingQuestions &&
{!displayQuestions &&
(queue.length > 0 || changedFilesForSubChat.length > 0) && (
<div className="px-2 -mb-6 relative z-10">
<div className="w-full max-w-2xl mx-auto px-2">
Expand Down Expand Up @@ -4109,7 +4174,7 @@ const ChatViewInner = memo(function ChatViewInner({
<ScrollToBottomButton
containerRef={chatContainerRef}
onScrollToBottom={scrollToBottom}
hasStackedCards={!pendingQuestions && (queue.length > 0 || changedFilesForSubChat.length > 0)}
hasStackedCards={!displayQuestions && (queue.length > 0 || changedFilesForSubChat.length > 0)}
subChatId={subChatId}
isActive={isActive}
/>
Expand Down