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 11 commits into
base: master
Choose a base branch
from
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),
});
registry.registerCommand(AI_CHAT_SHOW_CHATS_COMMAND, {
execute: () => this.selectChat(),
isEnabled: widget => this.withWidget(widget, () => true) && this.chatService.getSessions().length > 1,
Expand Down
26 changes: 25 additions & 1 deletion 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,6 +25,7 @@ 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;
Expand Down Expand Up @@ -63,6 +64,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 +85,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 +111,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 Down Expand Up @@ -137,10 +149,12 @@ 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;
Expand Down Expand Up @@ -319,6 +333,10 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
}
};

const handleUnpin = () => {
props.onUnpin();
};

const leftOptions = props.showContext ? [{
title: 'Attach elements to context',
handler: () => { /* TODO */ },
Expand Down Expand Up @@ -353,6 +371,12 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
<ChangeSetBox changeSet={changeSetUI} />
}
<div className='theia-ChatInput-Editor-Box'>
{props.pinnedAgent !== undefined &&
<div className='theia-ChatInput-Popup'>
<span>@{props.pinnedAgent.name}</span>
<span className="codicon codicon-remove-close option" title="unpin" onClick={handleUnpin} />
</div>
}
<div className='theia-ChatInput-Editor' ref={editorContainerRef} onKeyDown={onKeyDown} onFocus={handleInputFocus} onBlur={handleInputBlur}>
<div ref={placeholderRef} className='theia-ChatInput-Editor-Placeholder'>Ask a question</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')
};

export const AI_CHAT_SHOW_CHATS_COMMAND: Command = {
id: 'ai-chat-ui.show-chats',
iconClass: codicon('history')
Expand Down
11 changes: 11 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 @@ -113,10 +115,12 @@ export class ChatViewWidget extends BaseWidget implements ExtractableWidget, Sta
this.toDispose.push(
this.chatService.onActiveSessionChanged(event => {
const session = event.sessionId ? this.chatService.getSession(event.sessionId) : this.chatService.createSession();

if (session) {
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 +173,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 +183,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
17 changes: 17 additions & 0 deletions packages/ai-chat-ui/src/browser/style/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,23 @@ div:last-child > .theia-ChatNode {
text-overflow: ellipsis;
}

.theia-ChatInput-Popup {
position: relative;
bottom: -5px;
right: -2px;
padding-top: 9px;
padding-left: 10px;
padding-right: 10px;
padding-bottom: 11px;
display: flex;
flex-direction: row;
align-items: start;
align-self: flex-end;
gap: 10px;
border: var(--theia-border-width) solid var(--theia-dropdown-border);
border-radius: 4px;
}

.theia-ChatInput-ChangeSet-Header-Actions .codicon.action {
font-size: 18px;
height: 20px;
Expand Down
16 changes: 12 additions & 4 deletions packages/ai-chat/src/common/chat-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export interface ChatSession {
title?: string;
model: ChatModel;
isActive: boolean;
pinnedAgent?: ChatAgent;
}

export interface ActiveSessionChangedEvent {
Expand All @@ -78,7 +79,7 @@ export interface ChatService {

getSession(id: string): ChatSession | undefined;
getSessions(): ChatSession[];
createSession(location?: ChatAgentLocation, options?: SessionOptions): ChatSession;
createSession(location?: ChatAgentLocation, options?: SessionOptions, pinnedAgent?: ChatAgent): ChatSession;
deleteSession(sessionId: string): void;
setActiveSession(sessionId: string, options?: SessionOptions): void;

Expand Down Expand Up @@ -127,12 +128,13 @@ export class ChatServiceImpl implements ChatService {
return this._sessions.find(session => session.id === id);
}

createSession(location = ChatAgentLocation.Panel, options?: SessionOptions): ChatSession {
createSession(location = ChatAgentLocation.Panel, options?: SessionOptions, pinnedAgent?: ChatAgent): ChatSession {
const model = new ChatModelImpl(location);
const session: ChatSessionInternal = {
id: model.id,
model,
isActive: true
isActive: true,
pinnedAgent: pinnedAgent
};
this._sessions.push(session);
this.setActiveSession(session.id, options);
Expand Down Expand Up @@ -165,8 +167,14 @@ export class ChatServiceImpl implements ChatService {
session.title = request.text;

const parsedRequest = this.chatRequestParser.parseChatRequest(request, session.model.location);
let agent = this.getAgent(parsedRequest);

if (!session.pinnedAgent && agent && agent.id !== this.defaultChatAgentId?.id) {
session.pinnedAgent = agent;
} else if (session.pinnedAgent && this.getMentionedAgent(parsedRequest) === undefined) {
agent = session.pinnedAgent;
}

const agent = this.getAgent(parsedRequest);
if (agent === undefined) {
const error = 'No ChatAgents available to handle request!';
this.logger.error(error);
Expand Down