diff --git a/examples/api-samples/src/browser/chat/ask-and-continue-chat-agent-contribution.ts b/examples/api-samples/src/browser/chat/ask-and-continue-chat-agent-contribution.ts index f32dcddb85f72..60a1afb4cdc19 100644 --- a/examples/api-samples/src/browser/chat/ask-and-continue-chat-agent-contribution.ts +++ b/examples/api-samples/src/browser/chat/ask-and-continue-chat-agent-contribution.ts @@ -131,7 +131,7 @@ export class AskAndContinueChatAgent extends AbstractStreamParsingChatAgent { const question = content.replace(/^\n|<\/question>$/g, ''); const parsedQuestion = JSON.parse(question); - return new QuestionResponseContentImpl(parsedQuestion.question, parsedQuestion.options, request, selectedOption => { + return new QuestionResponseContentImpl(parsedQuestion.question, parsedQuestion.options, request, (selectedOption: { text: string; value?: string }) => { this.handleAnswer(selectedOption, request); }); }, diff --git a/packages/ai-chat-ui/src/browser/chat-response-renderer/question-part-renderer.tsx b/packages/ai-chat-ui/src/browser/chat-response-renderer/question-part-renderer.tsx index d085176882f1d..a96137348ded1 100644 --- a/packages/ai-chat-ui/src/browser/chat-response-renderer/question-part-renderer.tsx +++ b/packages/ai-chat-ui/src/browser/chat-response-renderer/question-part-renderer.tsx @@ -13,7 +13,9 @@ // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { ChatResponseContent, QuestionResponseContent } from '@theia/ai-chat'; +import { ChatResponseContent, MultiSelectQuestionResponseHandler, QuestionResponseContent, QuestionResponseHandler } from '@theia/ai-chat'; +import { nls } from '@theia/core'; +import { codicon } from '@theia/core/lib/browser'; import { injectable } from '@theia/core/shared/inversify'; import * as React from '@theia/core/shared/react'; import { ReactNode } from '@theia/core/shared/react'; @@ -32,33 +34,157 @@ export class QuestionPartRenderer } render(question: QuestionResponseContent, node: ResponseNode): ReactNode { - const isDisabled = question.isReadOnly || question.selectedOption !== undefined || !node.response.isWaitingForInput; + if (question.multiSelect) { + return ; + } + return ; + } - return ( -
-
{question.question}
-
- { - question.options.map((option, index) => ( - - )) - } -
-
- ); +} + +function isResolved(question: QuestionResponseContent): boolean { + return question.selectedOptions !== undefined; +} + +function skipQuestion(question: QuestionResponseContent): void { + if (!question.isReadOnly && question.handler) { + question.selectedOptions = []; + if (question.multiSelect) { + (question.handler as MultiSelectQuestionResponseHandler)([]); + } } +} + +function isOptionSelected(question: QuestionResponseContent, option: { text: string }): boolean { + return question.selectedOptions?.some(s => s.text === option.text) === true; +} + +function DismissButton({ question, disabled }: { question: QuestionResponseContent, disabled: boolean }): React.JSX.Element | undefined { + if (disabled) { + return undefined; + } + return ( + + )) + } + + + ); +} + +function MultiSelectQuestion({ question, node }: { question: QuestionResponseContent, node: ResponseNode }): React.JSX.Element { + const restoredIndices = React.useMemo(() => { + if (question.selectedOptions && question.selectedOptions.length > 0) { + const indices = new Set(); + for (const selected of question.selectedOptions) { + const idx = question.options.findIndex(o => o.text === selected.text); + if (idx >= 0) { + indices.add(idx); + } + } + return indices; + } + return new Set(); + }, []); + + const [selectedIndices, setSelectedIndices] = React.useState>(restoredIndices); + const [confirmed, setConfirmed] = React.useState(isResolved(question)); + const isDisabled = question.isReadOnly || confirmed || !node.response.isWaitingForInput; + const hasDescriptions = question.options.some(option => option.description); + + const toggleOption = React.useCallback((index: number): void => { + if (isDisabled) { + return; + } + setSelectedIndices(prev => { + const next = new Set(prev); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }, [isDisabled]); + + const handleConfirm = React.useCallback((): void => { + if (isDisabled || selectedIndices.size === 0) { + return; + } + const selectedOpts = Array.from(selectedIndices) + .sort((a, b) => a - b) + .map(i => question.options[i]); + question.selectedOptions = selectedOpts; + setConfirmed(true); + if (question.handler) { + (question.handler as MultiSelectQuestionResponseHandler)(selectedOpts); + } + }, [isDisabled, selectedIndices, question]); + + return ( +
+ + {question.header &&
{question.header}
} +
{question.question}
+
+ {question.options.map((option, index) => ( + + ))} +
+ {!isDisabled && ( + + )} +
+ ); } diff --git a/packages/ai-chat-ui/src/browser/style/index.css b/packages/ai-chat-ui/src/browser/style/index.css index 8a295289dd871..7108fcbc5fce7 100644 --- a/packages/ai-chat-ui/src/browser/style/index.css +++ b/packages/ai-chat-ui/src/browser/style/index.css @@ -759,33 +759,106 @@ div:last-child>.theia-ChatNode { } .theia-QuestionPartRenderer-root { + position: relative; display: flex; flex-direction: column; - gap: 8px; + gap: var(--theia-ui-padding); border: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border); - padding: 8px 12px 12px; - border-radius: 5px; - margin: 0 0 8px 0; + padding: calc(var(--theia-ui-padding) * 2); + border-radius: var(--theia-ui-padding); + margin: 0 0 var(--theia-ui-padding) 0; +} + +.theia-QuestionPartRenderer-dismiss { + position: absolute; + top: var(--theia-ui-padding); + right: var(--theia-ui-padding); + background: none; + border: none; + cursor: pointer; + color: var(--theia-descriptionForeground); + font-size: var(--theia-ui-font-size1); + padding: 2px; + line-height: 1; + border-radius: calc(var(--theia-ui-padding) * 2 / 3); +} + +.theia-QuestionPartRenderer-dismiss:hover { + color: var(--theia-foreground); + background-color: var(--theia-toolbar-hoverBackground); +} + +.theia-QuestionPartRenderer-header { + font-weight: 700; + font-size: var(--theia-ui-font-size0); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--theia-descriptionForeground); +} + +.theia-QuestionPartRenderer-question { + margin-bottom: calc(var(--theia-ui-padding) / 2); } .theia-QuestionPartRenderer-options { display: flex; flex-wrap: wrap; - gap: 12px; + gap: var(--theia-ui-padding); +} + +.theia-QuestionPartRenderer-options.has-descriptions { + flex-direction: column; } .theia-QuestionPartRenderer-option { + display: flex; + flex-direction: column; + align-items: flex-start; + text-align: left; min-width: 100px; flex: 1 1 auto; margin: 0; + padding: var(--theia-ui-padding) calc(var(--theia-ui-padding) * 2); + border-radius: var(--theia-ui-padding); + border: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border); + background-color: var(--theia-editor-background); + color: var(--theia-foreground); + cursor: pointer; + line-height: 1.4; +} + +.theia-QuestionPartRenderer-option:hover:not(:disabled) { + background-color: var(--theia-list-hoverBackground); +} + +.theia-QuestionPartRenderer-option.selected { + background-color: var(--theia-button-background); + color: var(--theia-button-foreground); + border-color: var(--theia-button-background); } .theia-QuestionPartRenderer-option.selected:disabled:hover { - background-color: var(--theia-button-disabledBackground); + background-color: var(--theia-button-background); } .theia-QuestionPartRenderer-option:disabled:not(.selected) { - background-color: var(--theia-button-secondaryBackground); + opacity: var(--theia-mod-disabled-opacity); + cursor: default; +} + +.theia-QuestionPartRenderer-option-label { + font-weight: 600; +} + +.theia-QuestionPartRenderer-option-description { + font-size: var(--theia-ui-font-size0); + opacity: 0.8; + margin-top: 2px; +} + +.theia-QuestionPartRenderer-confirm { + align-self: flex-end; + margin-top: calc(var(--theia-ui-padding) / 2); } .theia-toolCall, diff --git a/packages/ai-chat/src/common/chat-content-deserializer.ts b/packages/ai-chat/src/common/chat-content-deserializer.ts index 7f862b832f222..18a4fcc62506b 100644 --- a/packages/ai-chat/src/common/chat-content-deserializer.ts +++ b/packages/ai-chat/src/common/chat-content-deserializer.ts @@ -321,7 +321,10 @@ export class DefaultChatContentDeserializerContribution implements ChatContentDe data.options, undefined, undefined, - data.selectedOption + data.selectedOption, + data.multiSelect, + data.header, + data.selectedOptions ) }); } diff --git a/packages/ai-chat/src/common/chat-model-util.ts b/packages/ai-chat/src/common/chat-model-util.ts index 1bad8b6bad0c6..6edd3676c50fa 100644 --- a/packages/ai-chat/src/common/chat-model-util.ts +++ b/packages/ai-chat/src/common/chat-model-util.ts @@ -40,5 +40,7 @@ export function unansweredQuestions(request: ChatRequestModel): QuestionResponse function unansweredQuestionsOfResponse(response: ChatResponseModel | undefined): QuestionResponseContent[] { if (!response || !response.response) { return []; } - return response.response.content.filter((c): c is QuestionResponseContent => QuestionResponseContent.is(c) && c.selectedOption === undefined); + return response.response.content.filter((c): c is QuestionResponseContent => + QuestionResponseContent.is(c) && c.selectedOptions === undefined + ); } diff --git a/packages/ai-chat/src/common/chat-model.ts b/packages/ai-chat/src/common/chat-model.ts index eee7ed309eb2a..59e6bf761b31e 100644 --- a/packages/ai-chat/src/common/chat-model.ts +++ b/packages/ai-chat/src/common/chat-model.ts @@ -453,8 +453,11 @@ export interface ErrorContentData { */ export interface QuestionContentData { question: string; - options: { text: string; value?: string }[]; + header?: string; + options: { text: string; value?: string; description?: string }[]; + multiSelect?: boolean; selectedOption?: { text: string; value?: string }; + selectedOptions?: { text: string; value?: string }[]; } export interface TextChatResponseContent @@ -758,12 +761,19 @@ export type QuestionResponseHandler = ( selectedOption: { text: string, value?: string }, ) => void; +export type MultiSelectQuestionResponseHandler = ( + selectedOptions: { text: string, value?: string }[], +) => void; + export interface QuestionResponseContent extends ChatResponseContent { kind: 'question'; question: string; + header?: string; options: { text: string, value?: string, description?: string }[]; + multiSelect?: boolean; selectedOption?: { text: string, value?: string }; - handler?: QuestionResponseHandler; + selectedOptions?: { text: string, value?: string }[]; + handler?: QuestionResponseHandler | MultiSelectQuestionResponseHandler; request?: MutableChatRequestModel; /** * Whether this question is read-only (restored from persistence without handler). @@ -2486,47 +2496,73 @@ export class HorizontalLayoutChatResponseContentImpl implements HorizontalLayout */ export class QuestionResponseContentImpl implements QuestionResponseContent { readonly kind = 'question'; - protected _selectedOption: { text: string; value?: string } | undefined; + protected _selectedOptions: { text: string; value?: string }[] | undefined; constructor( public question: string, public options: { text: string, value?: string, description?: string }[], public request: MutableChatRequestModel | undefined, - public handler: QuestionResponseHandler | undefined, - selectedOption?: { text: string; value?: string } + public handler: QuestionResponseHandler | MultiSelectQuestionResponseHandler | undefined, + selectedOption?: { text: string; value?: string }, + public multiSelect?: boolean, + public header?: string, + selectedOptions?: { text: string; value?: string }[] ) { - this._selectedOption = selectedOption; + this._selectedOptions = selectedOptions ?? (selectedOption ? [selectedOption] : undefined); } get isReadOnly(): boolean { return !this.handler || !this.request; } - set selectedOption(option: { text: string; value?: string; } | undefined) { - this._selectedOption = option; - // Only trigger change notification if request is available (not in read-only mode) + set selectedOption(option: { text: string; value?: string } | undefined) { + this._selectedOptions = option ? [option] : undefined; + if (this.request) { + this.request.response.response.responseContentChanged(); + } + } + get selectedOption(): { text: string; value?: string } | undefined { + return this._selectedOptions?.[0]; + } + + set selectedOptions(options: { text: string; value?: string }[] | undefined) { + this._selectedOptions = options; if (this.request) { this.request.response.response.responseContentChanged(); } } - get selectedOption(): { text: string; value?: string; } | undefined { - return this._selectedOption; + get selectedOptions(): { text: string; value?: string }[] | undefined { + return this._selectedOptions; } + asString?(): string | undefined { - return `Question: ${this.question} -${this.selectedOption ? `Answer: ${this.selectedOption?.text}` : 'No answer'}`; + const answer = this._selectedOptions && this._selectedOptions.length > 0 + ? `Answer: ${this._selectedOptions.map(o => o.text).join(', ')}` + : 'No answer'; + return `Question: ${this.question}\n${answer}`; } merge?(): boolean { return false; } toSerializable(): SerializableChatResponseContentData { + const data: QuestionContentData = { + question: this.question, + options: this.options, + }; + if (this.multiSelect) { + data.selectedOptions = this._selectedOptions; + } else if (this._selectedOptions?.[0]) { + data.selectedOption = this._selectedOptions[0]; + } + if (this.header !== undefined) { + data.header = this.header; + } + if (this.multiSelect !== undefined) { + data.multiSelect = this.multiSelect; + } return { kind: 'question', - data: { - question: this.question, - options: this.options, - selectedOption: this._selectedOption - } + data }; } } diff --git a/packages/ai-claude-code/src/browser/claude-code-chat-agent.ts b/packages/ai-claude-code/src/browser/claude-code-chat-agent.ts index 26d58932ca05d..beb536eda0c85 100644 --- a/packages/ai-claude-code/src/browser/claude-code-chat-agent.ts +++ b/packages/ai-claude-code/src/browser/claude-code-chat-agent.ts @@ -380,7 +380,7 @@ export class ClaudeCodeChatAgent implements ChatAgent { question, APPROVAL_OPTIONS, request, - selectedOption => this.handleApprovalResponse(selectedOption, approvalRequest.requestId, approvalRequest.toolName, request) + (selectedOption: { text: string; value?: string }) => this.handleApprovalResponse(selectedOption, approvalRequest.requestId, approvalRequest.toolName, request) ); // Store references for this specific approval request @@ -465,41 +465,50 @@ export class ClaudeCodeChatAgent implements ChatAgent { description: opt.description })); - const questionText = questionItem.header - ? `**${questionItem.header}:** ${questionItem.question}` - : questionItem.question; + const onAnswered = (): void => { + answeredCount++; + if (answeredCount === totalQuestions) { + this.getPendingAskUserQuestions(request).delete(approvalRequest.requestId); - const questionContent = new QuestionResponseContentImpl( - questionText, - options, - request, - selectedOption => { - // Key by question text to match the Claude Code SDK's expected answers format - answers[questionItem.question] = selectedOption.value ?? selectedOption.text; - answeredCount++; + const updatedInput: AskUserQuestionInput = { + ...toolInput, + answers + }; - if (answeredCount === totalQuestions) { - this.getPendingAskUserQuestions(request).delete(approvalRequest.requestId); + const response: ToolApprovalResponseMessage = { + type: 'tool-approval-response', + requestId: approvalRequest.requestId, + approved: true, + updatedInput + }; - const updatedInput: AskUserQuestionInput = { - ...toolInput, - answers - }; + this.claudeCode.sendApprovalResponse(response); - const response: ToolApprovalResponseMessage = { - type: 'tool-approval-response', - requestId: approvalRequest.requestId, - approved: true, - updatedInput - }; - - this.claudeCode.sendApprovalResponse(response); - - if (this.getPendingApprovals(request).size === 0 && this.getPendingAskUserQuestions(request).size === 0) { - request.response.stopWaitingForInput(); - } + if (this.getPendingApprovals(request).size === 0 && this.getPendingAskUserQuestions(request).size === 0) { + request.response.stopWaitingForInput(); } } + }; + + const isMultiSelect = questionItem.multiSelect || undefined; + const handler = isMultiSelect + ? (selectedOptions: { text: string; value?: string }[]) => { + answers[questionItem.question] = selectedOptions.map(o => o.value ?? o.text).join(', '); + onAnswered(); + } + : (selectedOption: { text: string; value?: string }) => { + answers[questionItem.question] = selectedOption.value ?? selectedOption.text; + onAnswered(); + }; + + const questionContent = new QuestionResponseContentImpl( + questionItem.question, + options, + request, + handler, + undefined, + isMultiSelect, + questionItem.header ); request.response.response.addContent(questionContent); diff --git a/packages/ai-ide/src/browser/app-tester-chat-agent.ts b/packages/ai-ide/src/browser/app-tester-chat-agent.ts index 825ebd7d2ffca..9fb8be4237d91 100644 --- a/packages/ai-ide/src/browser/app-tester-chat-agent.ts +++ b/packages/ai-ide/src/browser/app-tester-chat-agent.ts @@ -70,8 +70,8 @@ export class AppTesterChatAgent extends AbstractStreamParsingChatAgent { { text: nls.localize('theia/ai/ide/app-tester/startMcpServers/no', 'No, cancel'), value: 'no' } ], request, - async selectedOption => { - if (selectedOption.value === 'yes') { + async (selectedOption: { text: string; value?: string }) => { + if (selectedOption?.value === 'yes') { const progress = request.response.addProgressMessage({ content: isNextVariant ? nls.localize('theia/ai/ide/app-tester/startChromeDevToolsMcpServers/progress', 'Starting Chrome DevTools MCP server.') diff --git a/packages/ai-ide/src/browser/github-chat-agent.ts b/packages/ai-ide/src/browser/github-chat-agent.ts index f9f0f861d97e7..f1f8a1d8b1ee6 100644 --- a/packages/ai-ide/src/browser/github-chat-agent.ts +++ b/packages/ai-ide/src/browser/github-chat-agent.ts @@ -78,8 +78,8 @@ export class GitHubChatAgent extends AbstractStreamParsingChatAgent { { text: nls.localize('theia/ai/ide/github/configureGitHubServer/no', 'No, cancel'), value: 'cancel' } ], request, - async selectedOption => { - if (selectedOption.value === 'configure') { + async (selectedOption: { text: string; value?: string }) => { + if (selectedOption?.value === 'configure') { await this.offerConfiguration(); request.response.response.addContent(new MarkdownChatResponseContentImpl(nls.localize('theia/ai/ide/github/configureGitHubServer/followup', 'Settings file opened. Please add your GitHub Personal Access Token to the `serverAuthToken` property in the GitHub server configuration, then ' @@ -107,8 +107,8 @@ export class GitHubChatAgent extends AbstractStreamParsingChatAgent { { text: nls.localize('theia/ai/ide/github/startGitHubServer/no', 'No, cancel'), value: 'no' } ], request, - async selectedOption => { - if (selectedOption.value === 'yes') { + async (selectedOption: { text: string; value?: string }) => { + if (selectedOption?.value === 'yes') { const progress = request.response.addProgressMessage({ content: nls.localize('theia/ai/ide/github/startGitHubServer/progress', 'Starting GitHub MCP server.'), show: 'whileIncomplete'