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),