Skip to content

Commit 7efd7de

Browse files
authored
Introduce editable chat requests (#15479)
Closes #15295
1 parent f9631e9 commit 7efd7de

16 files changed

+955
-57
lines changed

packages/ai-chat-ui/src/browser/ai-chat-ui-contribution.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import { formatDistance } from 'date-fns';
2828
import * as locales from 'date-fns/locale';
2929
import { AI_SHOW_SETTINGS_COMMAND } from '@theia/ai-core/lib/browser';
3030
import { OPEN_AI_HISTORY_VIEW } from '@theia/ai-history/lib/browser/ai-history-contribution';
31+
import { ChatNodeToolbarCommands } from './chat-node-toolbar-action-contribution';
32+
import { isEditableRequestNode, type EditableRequestNode } from './chat-tree-view';
3133

3234
export const AI_CHAT_TOGGLE_COMMAND_ID = 'aiChat:toggle';
3335

@@ -92,6 +94,20 @@ export class AIChatContribution extends AbstractViewContribution<ChatViewWidget>
9294
isEnabled: widget => this.withWidget(widget, () => true) && this.chatService.getSessions().length > 1,
9395
isVisible: widget => this.withWidget(widget, () => true)
9496
});
97+
registry.registerCommand(ChatNodeToolbarCommands.EDIT, {
98+
isEnabled: node => isEditableRequestNode(node) && !node.request.isEditing,
99+
isVisible: node => isEditableRequestNode(node) && !node.request.isEditing,
100+
execute: (node: EditableRequestNode) => {
101+
node.request.enableEdit();
102+
}
103+
});
104+
registry.registerCommand(ChatNodeToolbarCommands.CANCEL, {
105+
isEnabled: node => isEditableRequestNode(node) && node.request.isEditing,
106+
isVisible: node => isEditableRequestNode(node) && node.request.isEditing,
107+
execute: (node: EditableRequestNode) => {
108+
node.request.cancelEdit();
109+
}
110+
});
95111
}
96112

97113
registerToolbarItems(registry: TabBarToolbarRegistry): void {

packages/ai-chat-ui/src/browser/ai-chat-ui-frontend-module.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { ContainerModule, interfaces } from '@theia/core/shared/inversify';
2222
import { EditorSelectionResolver } from '@theia/editor/lib/browser/editor-manager';
2323
import { AIChatContribution } from './ai-chat-ui-contribution';
2424
import { AIChatInputConfiguration, AIChatInputWidget } from './chat-input-widget';
25-
import { ChatNodeToolbarActionContribution } from './chat-node-toolbar-action-contribution';
25+
import { ChatNodeToolbarActionContribution, DefaultChatNodeToolbarActionContribution } from './chat-node-toolbar-action-contribution';
2626
import { ChatResponsePartRenderer } from './chat-response-part-renderer';
2727
import {
2828
CodePartRenderer,
@@ -52,6 +52,7 @@ import { ChatViewWidgetToolbarContribution } from './chat-view-widget-toolbar-co
5252
import { ContextVariablePicker } from './context-variable-picker';
5353
import { ChangeSetActionRenderer, ChangeSetActionService } from './change-set-actions/change-set-action-service';
5454
import { ChangeSetAcceptAction } from './change-set-actions/change-set-accept-action';
55+
import { AIChatTreeInputArgs, AIChatTreeInputConfiguration, AIChatTreeInputFactory, AIChatTreeInputWidget } from './chat-tree-view/chat-view-tree-input-widget';
5556

5657
export default new ContainerModule((bind, _unbind, _isBound, rebind) => {
5758
bindViewContribution(bind, AIChatContribution);
@@ -64,8 +65,9 @@ export default new ContainerModule((bind, _unbind, _isBound, rebind) => {
6465
bind(AIChatInputWidget).toSelf();
6566
bind(AIChatInputConfiguration).toConstantValue({
6667
showContext: true,
67-
showPinnedAgent: true
68-
});
68+
showPinnedAgent: true,
69+
showChangeSet: true
70+
} satisfies AIChatInputConfiguration);
6971
bind(WidgetFactory).toDynamicValue(({ container }) => ({
7072
id: AIChatInputWidget.ID,
7173
createWidget: () => container.get(AIChatInputWidget)
@@ -79,6 +81,30 @@ export default new ContainerModule((bind, _unbind, _isBound, rebind) => {
7981
createWidget: () => container.get(ChatViewTreeWidget)
8082
})).inSingletonScope();
8183

84+
bind(AIChatTreeInputFactory).toFactory(ctx => (args: AIChatTreeInputArgs) => {
85+
const container = ctx.container.createChild();
86+
container.bind(AIChatTreeInputArgs).toConstantValue(args);
87+
container.bind(AIChatTreeInputConfiguration).toConstantValue({
88+
showContext: true,
89+
showPinnedAgent: true,
90+
showChangeSet: false
91+
} satisfies AIChatInputConfiguration);
92+
container.bind(AIChatTreeInputWidget).toSelf().inSingletonScope();
93+
const widget = container.get(AIChatTreeInputWidget);
94+
const noOp = () => { };
95+
widget.node.classList.add('chat-input-widget');
96+
widget.chatModel = args.node.request.session;
97+
widget.initialValue = args.initialValue;
98+
widget.setEnabled(true);
99+
widget.onQuery = args.onQuery;
100+
// We need to set those values here, otherwise the widget will throw an error
101+
widget.onUnpin = args.onUnpin ?? noOp;
102+
widget.onCancel = args.onCancel ?? noOp;
103+
widget.onDeleteChangeSet = args.onDeleteChangeSet ?? noOp;
104+
widget.onDeleteChangeSetElement = args.onDeleteChangeSetElement ?? noOp;
105+
return widget;
106+
});
107+
82108
bind(ContextVariablePicker).toSelf().inSingletonScope();
83109

84110
bind(ChatResponsePartRenderer).to(HorizontalLayoutPartRenderer).inSingletonScope();
@@ -115,6 +141,8 @@ export default new ContainerModule((bind, _unbind, _isBound, rebind) => {
115141
bind(ChangeSetActionRenderer).toService(ChangeSetAcceptAction);
116142

117143
bindContributionProvider(bind, ChatNodeToolbarActionContribution);
144+
bind(DefaultChatNodeToolbarActionContribution).toSelf().inSingletonScope();
145+
bind(ChatNodeToolbarActionContribution).toService(DefaultChatNodeToolbarActionContribution);
118146
});
119147

120148
function bindChatViewWidget(bind: interfaces.Bind): void {

packages/ai-chat-ui/src/browser/chat-input-widget.tsx

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export const AIChatInputConfiguration = Symbol('AIChatInputConfiguration');
3838
export interface AIChatInputConfiguration {
3939
showContext?: boolean;
4040
showPinnedAgent?: boolean;
41+
showChangeSet?: boolean;
4142
}
4243

4344
@injectable()
@@ -70,7 +71,7 @@ export class AIChatInputWidget extends ReactWidget {
7071
protected readonly changeSetActionService: ChangeSetActionService;
7172

7273
protected editorRef: MonacoEditor | undefined = undefined;
73-
private editorReady = new Deferred<void>();
74+
protected readonly editorReady = new Deferred<void>();
7475

7576
protected isEnabled = false;
7677

@@ -95,6 +96,11 @@ export class AIChatInputWidget extends ReactWidget {
9596
this._onDeleteChangeSetElement = deleteChangeSetElement;
9697
}
9798

99+
private _initialValue?: string;
100+
set initialValue(value: string | undefined) {
101+
this._initialValue = value;
102+
}
103+
98104
protected onDisposeForChatModel = new DisposableCollection();
99105
private _chatModel: ChatModel;
100106
set chatModel(chatModel: ChatModel) {
@@ -130,6 +136,10 @@ export class AIChatInputWidget extends ReactWidget {
130136
});
131137
}
132138

139+
protected getResourceUri(): URI {
140+
return new URI(`ai-chat:/input.${CHAT_VIEW_LANGUAGE_EXTENSION}`);
141+
}
142+
133143
protected render(): React.ReactNode {
134144
return (
135145
<ChatInput
@@ -142,11 +152,12 @@ export class AIChatInputWidget extends ReactWidget {
142152
onDeleteChangeSetElement={this._onDeleteChangeSetElement.bind(this)}
143153
onAddContextElement={this.addContextElement.bind(this)}
144154
onDeleteContextElement={this.deleteContextElement.bind(this)}
145-
context={this._chatModel.context.getVariables()}
155+
context={this.getContext()}
146156
chatModel={this._chatModel}
147157
pinnedAgent={this._pinnedAgent}
148158
editorProvider={this.editorProvider}
149159
resources={this.resources}
160+
resourceUriProvider={this.getResourceUri.bind(this)}
150161
contextMenuCallback={this.handleContextMenu.bind(this)}
151162
isEnabled={this.isEnabled}
152163
setEditorRef={editor => {
@@ -155,8 +166,10 @@ export class AIChatInputWidget extends ReactWidget {
155166
}}
156167
showContext={this.configuration?.showContext}
157168
showPinnedAgent={this.configuration?.showPinnedAgent}
169+
showChangeSet={this.configuration?.showChangeSet}
158170
labelProvider={this.labelProvider}
159171
actionService={this.changeSetActionService}
172+
initialValue={this._initialValue}
160173
/>
161174
);
162175
}
@@ -203,7 +216,7 @@ export class AIChatInputWidget extends ReactWidget {
203216
protected addContextElement(): void {
204217
this.contextVariablePicker.pickContextVariable().then(contextElement => {
205218
if (contextElement) {
206-
this._chatModel.context.addVariables(contextElement);
219+
this.addContext(contextElement);
207220
}
208221
});
209222
}
@@ -225,6 +238,10 @@ export class AIChatInputWidget extends ReactWidget {
225238
addContext(variable: AIVariableResolutionRequest): void {
226239
this._chatModel.context.addVariables(variable);
227240
}
241+
242+
protected getContext(): readonly AIVariableResolutionRequest[] {
243+
return this._chatModel.context.getVariables();
244+
}
228245
}
229246

230247
interface ChatInputProperties {
@@ -243,12 +260,15 @@ interface ChatInputProperties {
243260
pinnedAgent?: ChatAgent;
244261
editorProvider: MonacoEditorProvider;
245262
resources: InMemoryResources;
263+
resourceUriProvider: () => URI;
246264
contextMenuCallback: (event: IMouseEvent) => void;
247265
setEditorRef: (editor: MonacoEditor | undefined) => void;
248266
showContext?: boolean;
249267
showPinnedAgent?: boolean;
268+
showChangeSet?: boolean;
250269
labelProvider: LabelProvider;
251270
actionService: ChangeSetActionService;
271+
initialValue?: string;
252272
}
253273

254274
const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInputProperties) => {
@@ -276,7 +296,7 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
276296
const editorRef = React.useRef<MonacoEditor | undefined>(undefined);
277297

278298
React.useEffect(() => {
279-
const uri = new URI(`ai-chat:/input.${CHAT_VIEW_LANGUAGE_EXTENSION}`);
299+
const uri = props.resourceUriProvider();
280300
const resource = props.resources.add(uri, '');
281301
const createInputElement = async () => {
282302
const paddingTop = 6;
@@ -323,6 +343,7 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
323343
editorContainerRef.current.style.height = `${Math.min(contentHeight, maxHeight)}px`;
324344
}
325345
};
346+
326347
editor.getControl().onDidChangeModelContent(() => {
327348
const value = editor.getControl().getValue();
328349
setIsInputEmpty(!value || value.length === 0);
@@ -343,6 +364,10 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
343364

344365
editorRef.current = editor;
345366
props.setEditorRef(editor);
367+
368+
if (props.initialValue) {
369+
setValue(props.initialValue);
370+
}
346371
};
347372
createInputElement();
348373

@@ -408,16 +433,20 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
408433
return () => disposable.dispose();
409434
});
410435

436+
const setValue = React.useCallback((value: string) => {
437+
if (editorRef.current && !editorRef.current.document.isDisposed()) {
438+
editorRef.current.document.textEditorModel.setValue(value);
439+
}
440+
}, [editorRef]);
441+
411442
const submit = React.useCallback(function submit(value: string): void {
412443
if (!value || value.trim().length === 0) {
413444
return;
414445
}
415446
setInProgress(true);
416447
props.onQuery(value);
417-
if (editorRef.current) {
418-
editorRef.current.document.textEditorModel.setValue('');
419-
}
420-
}, [props.context, props.onQuery, editorRef]);
448+
setValue('');
449+
}, [props.context, props.onQuery, setValue]);
421450

422451
const onKeyDown = React.useCallback((event: React.KeyboardEvent) => {
423452
if (!props.isEnabled) {
@@ -518,7 +547,7 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
518547
const contextUI = buildContextUI(props.context, props.labelProvider, props.onDeleteContextElement);
519548

520549
return <div className='theia-ChatInput' onDragOver={props.onDragOver} onDrop={props.onDrop} >
521-
{changeSetUI?.elements &&
550+
{props.showChangeSet && changeSetUI?.elements &&
522551
<ChangeSetBox changeSet={changeSetUI} />
523552
}
524553
<div className='theia-ChatInput-Editor-Box'>

packages/ai-chat-ui/src/browser/chat-node-toolbar-action-contribution.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
//
1414
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
1515
// *****************************************************************************
16-
import { RequestNode, ResponseNode } from './chat-tree-view';
16+
import { Command, nls } from '@theia/core';
17+
import { codicon } from '@theia/core/lib/browser';
18+
import { isRequestNode, RequestNode, ResponseNode } from './chat-tree-view';
19+
import { EditableChatRequestModel } from '@theia/ai-chat';
1720

1821
export interface ChatNodeToolbarAction {
1922
/**
@@ -61,3 +64,39 @@ export interface ChatNodeToolbarActionContribution {
6164
*/
6265
getToolbarActions(node: RequestNode | ResponseNode): ChatNodeToolbarAction[];
6366
}
67+
68+
export namespace ChatNodeToolbarCommands {
69+
const CHAT_NODE_TOOLBAR_CATEGORY = 'ChatNodeToolbar';
70+
const CHAT_NODE_TOOLBAR_CATEGORY_KEY = nls.getDefaultKey(CHAT_NODE_TOOLBAR_CATEGORY);
71+
72+
export const EDIT = Command.toLocalizedCommand({
73+
id: 'chat:node:toolbar:edit-request',
74+
category: CHAT_NODE_TOOLBAR_CATEGORY,
75+
}, '', CHAT_NODE_TOOLBAR_CATEGORY_KEY);
76+
77+
export const CANCEL = Command.toLocalizedCommand({
78+
id: 'chat:node:toolbar:cancel-request',
79+
category: CHAT_NODE_TOOLBAR_CATEGORY,
80+
}, '', CHAT_NODE_TOOLBAR_CATEGORY_KEY);
81+
}
82+
83+
export class DefaultChatNodeToolbarActionContribution implements ChatNodeToolbarActionContribution {
84+
getToolbarActions(node: RequestNode | ResponseNode): ChatNodeToolbarAction[] {
85+
if (isRequestNode(node)) {
86+
if (EditableChatRequestModel.isEditing(node.request)) {
87+
return [{
88+
commandId: ChatNodeToolbarCommands.CANCEL.id,
89+
icon: codicon('close'),
90+
tooltip: nls.localize('theia/ai/chat-ui/node/toolbar/cancel', 'Cancel'),
91+
}];
92+
}
93+
return [{
94+
commandId: ChatNodeToolbarCommands.EDIT.id,
95+
icon: codicon('edit'),
96+
tooltip: nls.localize('theia/ai/chat-ui/node/toolbar/edit', 'Edit'),
97+
}];
98+
} else {
99+
return [];
100+
}
101+
}
102+
}

0 commit comments

Comments
 (0)