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
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export class AskAndContinueChatAgent extends AbstractStreamParsingChatAgent {
const question = content.replace(/^<question>\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);
});
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 <MultiSelectQuestion question={question} node={node} />;
}
return <SingleSelectQuestion question={question} node={node} />;
}

return (
<div className="theia-QuestionPartRenderer-root">
<div className="theia-QuestionPartRenderer-question">{question.question}</div>
<div className="theia-QuestionPartRenderer-options">
{
question.options.map((option, index) => (
<button
className={`theia-button theia-QuestionPartRenderer-option ${question.selectedOption?.text === option.text ? 'selected' : ''}`}
onClick={() => {
if (!question.isReadOnly && question.handler) {
question.selectedOption = option;
question.handler(option);
}
}}
disabled={isDisabled}
key={index}
title={option.description}
>
{option.text}
</button>
))
}
</div>
</div>
);
}

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 (
<button
className={`theia-QuestionPartRenderer-dismiss ${codicon('close')}`}
onClick={() => skipQuestion(question)}
title={nls.localizeByDefault('Dismiss')}
/>
);
}

function SingleSelectQuestion({ question, node }: { question: QuestionResponseContent, node: ResponseNode }): React.JSX.Element {
const isDisabled = question.isReadOnly || isResolved(question) || !node.response.isWaitingForInput;
const hasDescriptions = question.options.some(option => option.description);

return (
<div className="theia-QuestionPartRenderer-root">
<DismissButton question={question} disabled={isDisabled} />
{question.header && <div className="theia-QuestionPartRenderer-header">{question.header}</div>}
<div className="theia-QuestionPartRenderer-question">{question.question}</div>
<div className={`theia-QuestionPartRenderer-options ${hasDescriptions ? 'has-descriptions' : ''}`}>
{
question.options.map((option, index) => (
<button
className={`theia-QuestionPartRenderer-option ${isOptionSelected(question, option) ? 'selected' : ''}`}
onClick={() => {
if (!question.isReadOnly && question.handler) {
question.selectedOption = option;
(question.handler as QuestionResponseHandler)(option);
}
}}
disabled={isDisabled}
key={index}
>
<span className="theia-QuestionPartRenderer-option-label">{option.text}</span>
{option.description && (
<span className="theia-QuestionPartRenderer-option-description">{option.description}</span>
)}
</button>
))
}
</div>
</div>
);
}

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<number>();
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<number>();
}, []);

const [selectedIndices, setSelectedIndices] = React.useState<Set<number>>(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 (
<div className="theia-QuestionPartRenderer-root">
<DismissButton question={question} disabled={isDisabled} />
{question.header && <div className="theia-QuestionPartRenderer-header">{question.header}</div>}
<div className="theia-QuestionPartRenderer-question">{question.question}</div>
<div className={`theia-QuestionPartRenderer-options ${hasDescriptions ? 'has-descriptions' : ''}`}>
{question.options.map((option, index) => (
<button
className={`theia-QuestionPartRenderer-option ${selectedIndices.has(index) ? 'selected' : ''}`}
onClick={() => toggleOption(index)}
disabled={isDisabled}
key={index}
>
<span className="theia-QuestionPartRenderer-option-label">{option.text}</span>
{option.description && (
<span className="theia-QuestionPartRenderer-option-description">{option.description}</span>
)}
</button>
))}
</div>
{!isDisabled && (
<button
className="theia-QuestionPartRenderer-confirm theia-button main"
onClick={handleConfirm}
disabled={selectedIndices.size === 0}
>
{nls.localize('theia/ai-chat-ui/confirm', 'Confirm')}
</button>
)}
</div>
);
}
87 changes: 80 additions & 7 deletions packages/ai-chat-ui/src/browser/style/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion packages/ai-chat/src/common/chat-content-deserializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,10 @@ export class DefaultChatContentDeserializerContribution implements ChatContentDe
data.options,
undefined,
undefined,
data.selectedOption
data.selectedOption,
data.multiSelect,
data.header,
data.selectedOptions
)
});
}
Expand Down
4 changes: 3 additions & 1 deletion packages/ai-chat/src/common/chat-model-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
Loading
Loading