Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature - Chat Agent Pinning #14716

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
10 changes: 8 additions & 2 deletions packages/ai-chat-ui/src/browser/ai-chat-ui-contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
import { inject, injectable } from '@theia/core/shared/inversify';
import { CommandRegistry, QuickInputButton, QuickInputService, QuickPickItem } from '@theia/core';
import { Widget } from '@theia/core/lib/browser';
import { AI_CHAT_NEW_CHAT_WINDOW_COMMAND, AI_CHAT_SHOW_CHATS_COMMAND, ChatCommands } from './chat-view-commands';
import { ChatAgentLocation, ChatService } from '@theia/ai-chat';
import { AI_CHAT_NEW_CHAT_WINDOW_COMMAND, AI_CHAT_NEW_CHAT_WINDOW_WITH_PINNED_AGENT_COMMAND, AI_CHAT_SHOW_CHATS_COMMAND, ChatCommands } from './chat-view-commands';
import { ChatAgent, ChatAgentLocation, ChatService } from '@theia/ai-chat';
import { AbstractViewContribution } from '@theia/core/lib/browser/shell/view-contribution';
import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { ChatViewWidget } from './chat-view-widget';
Expand Down Expand Up @@ -79,6 +79,12 @@ export class AIChatContribution extends AbstractViewContribution<ChatViewWidget>
isEnabled: widget => this.withWidget(widget, () => true),
isVisible: widget => this.withWidget(widget, () => true),
});
registry.registerCommand(AI_CHAT_NEW_CHAT_WINDOW_WITH_PINNED_AGENT_COMMAND, {
// TODO - not working if function arg is set to type ChatAgent | undefined ?
execute: (...args: unknown[]) => this.chatService.createSession(ChatAgentLocation.Panel, {focus: true}, args[1] as ChatAgent | undefined),
isEnabled: widget => this.withWidget(widget, () => true),
isVisible: widget => this.withWidget(widget, () => true),
});
Comment on lines +82 to +87
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently this command is never invoked anywhere

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the extension we are developing for our course, we are using this command to directly open a chat widget with an agent pinned. If you don't have such a functionality expectation, I can remove it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I don't think we need this in Theia at the moment.

registry.registerCommand(AI_CHAT_SHOW_CHATS_COMMAND, {
execute: () => this.selectChat(),
isEnabled: widget => this.withWidget(widget, () => true) && this.chatService.getSessions().length > 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ export default new ContainerModule((bind, _unbind, _isBound, rebind) => {

bind(AIChatInputWidget).toSelf();
bind(AIChatInputConfiguration).toConstantValue({
showContext: false
showContext: false,
showPinnedAgent: true
});
bind(WidgetFactory).toDynamicValue(({ container }) => ({
id: AIChatInputWidget.ID,
Expand Down
78 changes: 68 additions & 10 deletions packages/ai-chat-ui/src/browser/chat-input-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ChangeSet, ChangeSetElement, ChatChangeEvent, ChatModel, ChatRequestModel } from '@theia/ai-chat';
import { ChangeSet, ChangeSetElement, ChatAgent, ChatChangeEvent, ChatModel, ChatRequestModel } from '@theia/ai-chat';
import { Disposable, UntitledResourceResolver } from '@theia/core';
import { ContextMenuRenderer, LabelProvider, Message, ReactWidget } from '@theia/core/lib/browser';
import { Deferred } from '@theia/core/lib/common/promise-util';
Expand All @@ -25,13 +25,15 @@ import { MonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-pr
import { CHAT_VIEW_LANGUAGE_EXTENSION } from './chat-view-language-contribution';

type Query = (query: string) => Promise<void>;
type Unpin = () => void;
type Cancel = (requestModel: ChatRequestModel) => void;
type DeleteChangeSet = (requestModel: ChatRequestModel) => void;
type DeleteChangeSetElement = (requestModel: ChatRequestModel, index: number) => void;

export const AIChatInputConfiguration = Symbol('AIChatInputConfiguration');
export interface AIChatInputConfiguration {
showContext?: boolean;
showPinnedAgent?: boolean;
}

@injectable()
Expand Down Expand Up @@ -63,6 +65,10 @@ export class AIChatInputWidget extends ReactWidget {
set onQuery(query: Query) {
this._onQuery = query;
}
private _onUnpin: Unpin;
set onUnpin(unpin: Unpin) {
this._onUnpin = unpin;
}
private _onCancel: Cancel;
set onCancel(cancel: Cancel) {
this._onCancel = cancel;
Expand All @@ -80,6 +86,11 @@ export class AIChatInputWidget extends ReactWidget {
this._chatModel = chatModel;
this.update();
}
private _pinnedAgent: ChatAgent | undefined;
set pinnedAgent(pinnedAgent: ChatAgent | undefined) {
this._pinnedAgent = pinnedAgent;
this.update();
}

@postConstruct()
protected init(): void {
Expand All @@ -101,10 +112,12 @@ export class AIChatInputWidget extends ReactWidget {
return (
<ChatInput
onQuery={this._onQuery.bind(this)}
onUnpin={this._onUnpin.bind(this)}
onCancel={this._onCancel.bind(this)}
onDeleteChangeSet={this._onDeleteChangeSet.bind(this)}
onDeleteChangeSetElement={this._onDeleteChangeSetElement.bind(this)}
chatModel={this._chatModel}
pinnedAgent={this._pinnedAgent}
editorProvider={this.editorProvider}
untitledResourceResolver={this.untitledResourceResolver}
contextMenuCallback={this.handleContextMenu.bind(this)}
Expand All @@ -114,6 +127,7 @@ export class AIChatInputWidget extends ReactWidget {
this.editorReady.resolve();
}}
showContext={this.configuration?.showContext}
showPinnedAgent={this.configuration?.showPinnedAgent}
labelProvider={this.labelProvider}
/>
);
Expand All @@ -137,15 +151,18 @@ export class AIChatInputWidget extends ReactWidget {
interface ChatInputProperties {
onCancel: (requestModel: ChatRequestModel) => void;
onQuery: (query: string) => void;
onUnpin: () => void;
onDeleteChangeSet: (sessionId: string) => void;
onDeleteChangeSetElement: (sessionId: string, index: number) => void;
isEnabled?: boolean;
chatModel: ChatModel;
pinnedAgent?: ChatAgent;
editorProvider: MonacoEditorProvider;
untitledResourceResolver: UntitledResourceResolver;
contextMenuCallback: (event: IMouseEvent) => void;
setEditorRef: (editor: MonacoEditor | undefined) => void;
showContext?: boolean;
showPinnedAgent?: boolean;
labelProvider: LabelProvider;
}

Expand Down Expand Up @@ -319,11 +336,42 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
}
};

const leftOptions = props.showContext ? [{
title: 'Attach elements to context',
handler: () => { /* TODO */ },
className: 'codicon-add'
}] : [];
const handlePin = () => {
if (editorRef.current) {
editorRef.current.getControl().getModel()?.applyEdits([{
range: {
startLineNumber: 1,
startColumn: 1,
endLineNumber: 1,
endColumn: 1
},
text: '@ ',
}]);
editorRef.current.getControl().setPosition({ lineNumber: 1, column: 2 });
editorRef.current.getControl().getAction('editor.action.triggerSuggest')?.run();
}
};

const leftOptions = [
...(props.showContext
? [{
title: 'Attach elements to context',
handler: () => { /* TODO */ },
className: 'codicon-add'
}]
: []),
...(props.showPinnedAgent
? [{
title: props.pinnedAgent ? 'Unpin Agent' : 'Pin Agent',
handler: props.pinnedAgent ? props.onUnpin : handlePin,
className: 'at-icon',
text: {
align: 'right',
content: props.pinnedAgent && props.pinnedAgent.name
},
}]
: []),
] as Option[];

const rightOptions = inProgress
? [{
Expand Down Expand Up @@ -454,6 +502,10 @@ interface Option {
handler: () => void;
className: string;
disabled?: boolean;
text?: {
align?: 'left' | 'right';
content: string;
};
}

const ChatInputOptions: React.FunctionComponent<ChatInputOptionsProps> = ({ leftOptions, rightOptions }) => (
Expand All @@ -462,20 +514,26 @@ const ChatInputOptions: React.FunctionComponent<ChatInputOptionsProps> = ({ left
{leftOptions.map((option, index) => (
<span
key={index}
className={`codicon ${option.className} option ${option.disabled ? 'disabled' : ''}`}
className={`option ${option.disabled ? 'disabled' : ''} ${option.text?.align === 'right' ? 'reverse' : ''}`}
title={option.title}
onClick={option.handler}
/>
>
<span>{option.text?.content}</span>
<span className={`codicon ${option.className}`} />
</span>
))}
</div>
<div className="theia-ChatInputOptions-right">
{rightOptions.map((option, index) => (
<span
key={index}
className={`codicon ${option.className} option ${option.disabled ? 'disabled' : ''}`}
className={`option ${option.disabled ? 'disabled' : ''} ${option.text?.align === 'right' ? 'reverse' : ''}`}
title={option.title}
onClick={option.handler}
/>
>
<span>{option.text?.content}</span>
<span className={`codicon ${option.className}`}/>
</span>
))}
</div>
</div>
Expand Down
5 changes: 5 additions & 0 deletions packages/ai-chat-ui/src/browser/chat-view-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ export const AI_CHAT_NEW_CHAT_WINDOW_COMMAND: Command = {
iconClass: codicon('add')
};

export const AI_CHAT_NEW_CHAT_WINDOW_WITH_PINNED_AGENT_COMMAND: Command = {
id: 'ai-chat-ui.new-chat-with-pinned-agent',
iconClass: codicon('add')
};

Comment on lines 41 to +46
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really used at the moment.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now, I'd remove this.

export const AI_CHAT_SHOW_CHATS_COMMAND: Command = {
id: 'ai-chat-ui.show-chats',
iconClass: codicon('history')
Expand Down
10 changes: 10 additions & 0 deletions packages/ai-chat-ui/src/browser/chat-view-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,10 @@ export class ChatViewWidget extends BaseWidget implements ExtractableWidget, Sta
this.chatSession = this.chatService.createSession();

this.inputWidget.onQuery = this.onQuery.bind(this);
this.inputWidget.onUnpin = this.onUnpin.bind(this);
this.inputWidget.onCancel = this.onCancel.bind(this);
this.inputWidget.chatModel = this.chatSession.model;
this.inputWidget.pinnedAgent = this.chatSession.pinnedAgent;
this.inputWidget.onDeleteChangeSet = this.onDeleteChangeSet.bind(this);
this.inputWidget.onDeleteChangeSetElement = this.onDeleteChangeSetElement.bind(this);
this.treeWidget.trackChatModel(this.chatSession.model);
Expand All @@ -117,6 +119,7 @@ export class ChatViewWidget extends BaseWidget implements ExtractableWidget, Sta
this.chatSession = session;
this.treeWidget.trackChatModel(this.chatSession.model);
this.inputWidget.chatModel = this.chatSession.model;
this.inputWidget.pinnedAgent = this.chatSession.pinnedAgent;
if (event.focus) {
this.show();
}
Expand Down Expand Up @@ -169,6 +172,8 @@ export class ChatViewWidget extends BaseWidget implements ExtractableWidget, Sta
if (responseModel.isError) {
this.messageService.error(responseModel.errorObject?.message ?? 'An error occurred during chat service invocation.');
}
}).finally(() => {
this.inputWidget.pinnedAgent = this.chatSession.pinnedAgent;
});
if (!requestProgress) {
this.messageService.error(`Was not able to send request "${chatRequest.text}" to session ${this.chatSession.id}`);
Expand All @@ -177,6 +182,11 @@ export class ChatViewWidget extends BaseWidget implements ExtractableWidget, Sta
// Tree Widget currently tracks the ChatModel itself. Therefore no notification necessary.
}

protected onUnpin(): void {
this.chatSession.pinnedAgent = undefined;
this.inputWidget.pinnedAgent = this.chatSession.pinnedAgent;
}

protected onCancel(requestModel: ChatRequestModel): void {
this.chatService.cancelRequest(requestModel.session.id, requestModel.id);
}
Expand Down
11 changes: 9 additions & 2 deletions packages/ai-chat-ui/src/browser/style/index.css
atahankilc marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -381,10 +381,13 @@ div:last-child > .theia-ChatNode {
}

.theia-ChatInputOptions .option {
width: 21px;
min-width: 21px;
height: 21px;
padding: 2px;
display: inline-block;
display: flex;
justify-content: space-between;
align-items: center;
gap: 2px;
box-sizing: border-box;
user-select: none;
background-repeat: no-repeat;
Expand All @@ -405,6 +408,10 @@ div:last-child > .theia-ChatNode {
background-color: var(--theia-toolbar-hoverBackground);
}

.theia-ChatInputOptions .reverse {
flex-direction: row-reverse;
}

.theia-CodePartRenderer-root {
display: flex;
flex-direction: column;
Expand Down
4 changes: 3 additions & 1 deletion packages/ai-chat/src/browser/ai-chat-frontend-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import {
ChatRequestParser,
ChatRequestParserImpl,
ChatService,
DefaultChatAgentId
DefaultChatAgentId,
PinChatAgent
} from '../common';
import { ChatAgentsVariableContribution } from '../common/chat-agents-variable-contribution';
import { CommandChatAgent } from '../common/command-chat-agents';
Expand All @@ -51,6 +52,7 @@ export default new ContainerModule(bind => {
bind(ChatAgentServiceImpl).toSelf().inSingletonScope();
bind(ChatAgentService).toService(ChatAgentServiceImpl);
bind(DefaultChatAgentId).toConstantValue({ id: OrchestratorChatAgentId });
bind(PinChatAgent).toConstantValue(true);

bindContributionProvider(bind, ResponseContentMatcherProvider);
bind(DefaultResponseContentMatcherProvider).toSelf().inSingletonScope();
Expand Down
9 changes: 9 additions & 0 deletions packages/ai-chat/src/browser/ai-chat-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { AI_CORE_PREFERENCES_TITLE } from '@theia/ai-core/lib/browser/ai-core-pr
import { PreferenceSchema } from '@theia/core/lib/browser/preferences/preference-contribution';

export const DEFAULT_CHAT_AGENT_PREF = 'ai-features.chat.defaultChatAgent';
export const PIN_CHAT_AGENT_PREF = 'ai-features.chat.pinChatAgent';

export const aiChatPreferences: PreferenceSchema = {
type: 'object',
Expand All @@ -27,6 +28,14 @@ export const aiChatPreferences: PreferenceSchema = {
description: 'Optional: <agent-name> of the Chat Agent that shall be invoked, if no agent is explicitly mentioned with @<agent-name> in the user query.\
If no Default Agent is configured, Theia´s defaults will be applied.',
title: AI_CORE_PREFERENCES_TITLE,
},
[PIN_CHAT_AGENT_PREF]: {
type: 'boolean',
description: 'Enable agent pinning to automatically keep a mentioned chat agent active across prompts, reducing the need for repeated mentions.\
\n\
You can manually unpin or switch agents anytime.',
default: true,
title: AI_CORE_PREFERENCES_TITLE,
}
}
};
19 changes: 16 additions & 3 deletions packages/ai-chat/src/browser/frontend-chat-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,30 @@
// *****************************************************************************

import { inject, injectable } from '@theia/core/shared/inversify';
import { ChatAgent, ChatServiceImpl, ParsedChatRequest } from '../common';
import { ChatAgent, ChatServiceImpl, ChatSession, ParsedChatRequest } from '../common';
import { PreferenceService } from '@theia/core/lib/browser';
import { DEFAULT_CHAT_AGENT_PREF } from './ai-chat-preferences';
import { DEFAULT_CHAT_AGENT_PREF, PIN_CHAT_AGENT_PREF } from './ai-chat-preferences';

@injectable()
export class FrontendChatServiceImpl extends ChatServiceImpl {

@inject(PreferenceService)
protected preferenceService: PreferenceService;

protected override getAgent(parsedRequest: ParsedChatRequest): ChatAgent | undefined {
protected override getAgent(parsedRequest: ParsedChatRequest, session: ChatSession): ChatAgent | undefined {
let agent = this.initialAgentSelection(parsedRequest);
if (!this.preferenceService.get<boolean>(PIN_CHAT_AGENT_PREF)) {
return agent;
}
if (!session.pinnedAgent && agent && agent.id !== this.defaultChatAgentId?.id) {
session.pinnedAgent = agent;
} else if (session.pinnedAgent && this.getMentionedAgent(parsedRequest) === undefined) {
agent = session.pinnedAgent;
}
return agent;
}

protected override initialAgentSelection(parsedRequest: ParsedChatRequest): ChatAgent | undefined {
const agentPart = this.getMentionedAgent(parsedRequest);
if (agentPart) {
return this.chatAgentService.getAgent(agentPart.agentId);
Expand Down
Loading
Loading