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

Merged
merged 20 commits into from
Feb 17, 2025
Merged
Show file tree
Hide file tree
Changes from 12 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
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),
});
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
Contributor 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
59 changes: 49 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,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,11 +333,26 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
}
};

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

const rightOptions = inProgress
? [{
Expand Down Expand Up @@ -454,6 +483,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 +495,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')
};

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
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
28 changes: 26 additions & 2 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 Expand Up @@ -381,10 +398,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 +425,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
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