diff --git a/desktop/src/components/chat/AskUserQuestion.test.tsx b/desktop/src/components/chat/AskUserQuestion.test.tsx
index 0e4171e1..4e5b030d 100644
--- a/desktop/src/components/chat/AskUserQuestion.test.tsx
+++ b/desktop/src/components/chat/AskUserQuestion.test.tsx
@@ -296,12 +296,12 @@ describe('AskUserQuestion', () => {
})
fireEvent.click(screen.getByRole('button', { name: /Q2$/ }))
- expect((screen.getByPlaceholderText('Type your answer...') as HTMLInputElement).value).toBe('')
+ expect((screen.getByPlaceholderText('Type your answer...') as HTMLTextAreaElement).value).toBe('')
fireEvent.click(screen.getByRole('button', { name: /^A2$/ }))
fireEvent.click(screen.getByRole('button', { name: /Q1$/ }))
- expect((screen.getByPlaceholderText('Type your answer...') as HTMLInputElement).value).toBe('custom-q1')
+ expect((screen.getByPlaceholderText('Type your answer...') as HTMLTextAreaElement).value).toBe('custom-q1')
fireEvent.click(screen.getByRole('button', { name: /submit/i }))
@@ -318,4 +318,49 @@ describe('AskUserQuestion', () => {
},
})
})
+
+ it('uses a multiline custom response box and submits it with Ctrl+Enter', () => {
+ render(
+ ,
+ )
+
+ const textarea = screen.getByPlaceholderText('Type your answer...')
+ expect(textarea.tagName).toBe('TEXTAREA')
+ expect(textarea.getAttribute('rows')).toBe('3')
+
+ fireEvent.change(textarea, {
+ target: { value: 'First restored context line\nSecond restored context line' },
+ })
+ fireEvent.keyDown(textarea, { key: 'Enter' })
+ expect(sendMock).not.toHaveBeenCalled()
+
+ fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
+
+ expect(sendMock).toHaveBeenCalledWith(ACTIVE_TAB, {
+ type: 'permission_response',
+ requestId: 'perm-1',
+ allowed: true,
+ updatedInput: {
+ questions: [
+ {
+ question: 'What context should we restore?',
+ options: [{ label: 'Skip' }],
+ },
+ ],
+ answers: {
+ 'What context should we restore?': 'First restored context line\nSecond restored context line',
+ },
+ },
+ })
+ })
})
diff --git a/desktop/src/components/chat/AskUserQuestion.tsx b/desktop/src/components/chat/AskUserQuestion.tsx
index ecd71cc8..e7acbcc9 100644
--- a/desktop/src/components/chat/AskUserQuestion.tsx
+++ b/desktop/src/components/chat/AskUserQuestion.tsx
@@ -320,18 +320,22 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props)
- handleFreeTextChange(safeActiveTab, e.target.value)}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onKeyDown={(e) => {
if (composingRef.current || e.nativeEvent.isComposing || e.keyCode === 229) return
- if (e.key === 'Enter' && allAnswered) handleSubmit()
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && allAnswered) {
+ e.preventDefault()
+ handleSubmit()
+ }
}}
placeholder={t('question.typePlaceholder')}
- className="w-full px-3 py-2 text-sm bg-[var(--color-surface)] border border-[var(--color-outline-variant)]/40 rounded-[var(--radius-md)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-secondary)] focus:ring-1 focus:ring-[var(--color-secondary)]/30"
+ rows={3}
+ wrap="soft"
+ className="max-h-48 min-h-[84px] w-full resize-y rounded-[var(--radius-md)] border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface)] px-3 py-2 text-sm leading-relaxed text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-secondary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-secondary)]/30"
/>
)}
diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx
index 12b66416..f03f1c18 100644
--- a/desktop/src/components/chat/MessageList.test.tsx
+++ b/desktop/src/components/chat/MessageList.test.tsx
@@ -153,6 +153,104 @@ describe('MessageList nested tool calls', () => {
expect(container.querySelector('[data-virtual-message-item]')).toBeNull()
})
+ it('filters duplicate unresolved AskUserQuestion cards while a matching permission is pending', () => {
+ const messages: UIMessage[] = [
+ {
+ id: 'stale-ask',
+ type: 'tool_use',
+ toolName: 'AskUserQuestion',
+ toolUseId: 'stale-tool',
+ input: {
+ questions: [
+ {
+ question: 'Restore this context?',
+ options: [{ label: 'No' }, { label: 'Yes' }],
+ },
+ ],
+ },
+ timestamp: 1,
+ },
+ {
+ id: 'active-ask',
+ type: 'tool_use',
+ toolName: 'AskUserQuestion',
+ toolUseId: 'active-tool',
+ input: {
+ questions: [
+ {
+ question: 'Restore this context?',
+ options: [{ label: 'No' }, { label: 'Yes' }],
+ },
+ ],
+ },
+ timestamp: 2,
+ },
+ ]
+
+ const { renderItems } = buildRenderModel(messages, 'active-tool')
+
+ expect(renderItems).toHaveLength(1)
+ expect(renderItems[0]).toMatchObject({
+ kind: 'message',
+ message: {
+ type: 'tool_use',
+ toolName: 'AskUserQuestion',
+ toolUseId: 'active-tool',
+ },
+ })
+ })
+
+ it('keeps resolved AskUserQuestion history visible when filtering active duplicates', () => {
+ const messages: UIMessage[] = [
+ {
+ id: 'answered-ask',
+ type: 'tool_use',
+ toolName: 'AskUserQuestion',
+ toolUseId: 'answered-tool',
+ input: {
+ questions: [
+ {
+ question: 'Already answered?',
+ options: [{ label: 'No' }, { label: 'Yes' }],
+ },
+ ],
+ },
+ timestamp: 1,
+ },
+ {
+ id: 'answered-result',
+ type: 'tool_result',
+ toolUseId: 'answered-tool',
+ content: { answers: { 'Already answered?': 'Yes' } },
+ isError: false,
+ timestamp: 2,
+ },
+ {
+ id: 'active-ask',
+ type: 'tool_use',
+ toolName: 'AskUserQuestion',
+ toolUseId: 'active-tool',
+ input: {
+ questions: [
+ {
+ question: 'Restore this context?',
+ options: [{ label: 'No' }, { label: 'Yes' }],
+ },
+ ],
+ },
+ timestamp: 3,
+ },
+ ]
+
+ const { renderItems } = buildRenderModel(messages, 'active-tool')
+
+ expect(renderItems).toHaveLength(2)
+ expect(renderItems.map((item) => item.kind === 'message' && item.message.type === 'tool_use'
+ ? item.message.toolUseId
+ : null,
+ )).toEqual(['answered-tool', 'active-tool'])
+ })
+
it('renders goal events as visible status cards', () => {
useChatStore.setState({
sessions: {
diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx
index 4ccb58f6..3b9da434 100644
--- a/desktop/src/components/chat/MessageList.tsx
+++ b/desktop/src/components/chat/MessageList.tsx
@@ -447,11 +447,12 @@ function appendChildToolCall(
}
}
-export function buildRenderModel(messages: UIMessage[]): RenderModel {
+export function buildRenderModel(messages: UIMessage[], activeAskUserQuestionToolUseId?: string | null): RenderModel {
const items: RenderItem[] = []
const toolResultMap = new Map()
const childToolCallsByParent = new Map()
const toolUseIds = new Set()
+ const lastUnresolvedAskUserQuestionIndexByToolUseId = new Map()
let pendingToolCalls: ToolCall[] = []
const flushGroup = () => {
@@ -482,6 +483,15 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel {
toolResultMap.set(msg.toolUseId, msg)
}
}
+ messages.forEach((msg, index) => {
+ if (
+ msg.type === 'tool_use' &&
+ msg.toolName === 'AskUserQuestion' &&
+ !toolResultMap.has(msg.toolUseId)
+ ) {
+ lastUnresolvedAskUserQuestionIndexByToolUseId.set(msg.toolUseId, index)
+ }
+ })
for (const msg of messages) {
if (msg.type === 'assistant_text' && !msg.content.trim()) {
@@ -505,6 +515,18 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel {
continue
}
if (msg.toolName === 'AskUserQuestion') {
+ const isResolved = toolResultMap.has(msg.toolUseId)
+ const lastUnresolvedIndex = lastUnresolvedAskUserQuestionIndexByToolUseId.get(msg.toolUseId)
+ if (!isResolved && lastUnresolvedIndex !== undefined && messages[lastUnresolvedIndex] !== msg) {
+ continue
+ }
+ if (
+ !isResolved &&
+ activeAskUserQuestionToolUseId &&
+ msg.toolUseId !== activeAskUserQuestionToolUseId
+ ) {
+ continue
+ }
flushGroup()
items.push({ kind: 'message', message: msg })
} else {
@@ -822,6 +844,10 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const streamingText = sessionState?.streamingText ?? ''
const activeThinkingId = sessionState?.activeThinkingId ?? null
const agentTaskNotifications = sessionState?.agentTaskNotifications ?? {}
+ const activeAskUserQuestionToolUseId =
+ sessionState?.pendingPermission?.toolName === 'AskUserQuestion'
+ ? sessionState.pendingPermission.toolUseId
+ : null
const shouldFollowContentResize =
streamingText.trim().length > 0 ||
chatState === 'streaming' ||
@@ -975,8 +1001,8 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
}, [scrollToBottom, shouldFollowContentResize])
const { toolResultMap, childToolCallsByParent, renderItems } = useMemo(
- () => buildRenderModel(messages),
- [messages],
+ () => buildRenderModel(messages, activeAskUserQuestionToolUseId),
+ [activeAskUserQuestionToolUseId, messages],
)
const branchableMessageTargets = useMemo(
() => branchActionsDisabled ? new Map() : getBranchableMessageTargets(messages),