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
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
@@ -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,
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
@@ -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';
@@ -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()
@@ -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;
@@ -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 {
@@ -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)}
@@ -114,6 +127,7 @@ export class AIChatInputWidget extends ReactWidget {
this.editorReady.resolve();
}}
showContext={this.configuration?.showContext}
showPinnedAgent={this.configuration?.showPinnedAgent}
labelProvider={this.labelProvider}
/>
);
@@ -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;
}

@@ -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
? [{
@@ -456,6 +504,10 @@ interface Option {
handler: () => void;
className: string;
disabled?: boolean;
text?: {
align?: 'left' | 'right';
content: string;
};
}

const ChatInputOptions: React.FunctionComponent<ChatInputOptionsProps> = ({ leftOptions, rightOptions }) => (
@@ -464,20 +516,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>
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
@@ -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);
@@ -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();
}
@@ -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}`);
@@ -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);
}
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
@@ -392,10 +392,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;
@@ -416,6 +419,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;
2 changes: 2 additions & 0 deletions packages/ai-chat/src/browser/ai-chat-frontend-module.ts
Original file line number Diff line number Diff line change
@@ -26,6 +26,7 @@ import {
ChatRequestParserImpl,
ChatService,
ToolCallChatResponseContentFactory,
PinChatAgent
} from '../common';
import { ChatAgentsVariableContribution } from '../common/chat-agents-variable-contribution';
import { CustomChatAgent } from '../common/custom-chat-agent';
@@ -47,6 +48,7 @@ export default new ContainerModule(bind => {

bind(ChatAgentServiceImpl).toSelf().inSingletonScope();
bind(ChatAgentService).toService(ChatAgentServiceImpl);
bind(PinChatAgent).toConstantValue(true);

bindContributionProvider(bind, ResponseContentMatcherProvider);
bind(DefaultResponseContentMatcherProvider).toSelf().inSingletonScope();
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
@@ -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',
@@ -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,
}
}
};
21 changes: 17 additions & 4 deletions packages/ai-chat/src/browser/frontend-chat-service.ts
Original file line number Diff line number Diff line change
@@ -15,9 +15,9 @@
// *****************************************************************************

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';

/**
* Customizes the ChatServiceImpl to consider preference based default chat agent
@@ -28,15 +28,28 @@ 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) {
const configuredDefaultChatAgent = this.getConfiguredDefaultChatAgent();
if (configuredDefaultChatAgent) {
return configuredDefaultChatAgent;
}
}
return super.getAgent(parsedRequest);
return super.initialAgentSelection(parsedRequest);
}

protected getConfiguredDefaultChatAgent(): ChatAgent | undefined {
31 changes: 26 additions & 5 deletions packages/ai-chat/src/common/chat-service.ts
Original file line number Diff line number Diff line change
@@ -56,6 +56,7 @@ export interface ChatSession {
title?: string;
model: ChatModel;
isActive: boolean;
pinnedAgent?: ChatAgent;
}

export interface ActiveSessionChangedEvent {
@@ -83,13 +84,16 @@ export interface FallbackChatAgentId {
id: string;
}

export const PinChatAgent = Symbol('PinChatAgent');
export type PinChatAgent = boolean;

export const ChatService = Symbol('ChatService');
export interface ChatService {
onActiveSessionChanged: Event<ActiveSessionChangedEvent>

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;

@@ -122,6 +126,9 @@ export class ChatServiceImpl implements ChatService {
@inject(FallbackChatAgentId) @optional()
protected fallbackChatAgentId: FallbackChatAgentId | undefined;

@inject(PinChatAgent) @optional()
protected pinChatAgent: boolean | undefined;

@inject(ChatRequestParser)
protected chatRequestParser: ChatRequestParser;

@@ -141,12 +148,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 MutableChatModel(location);
const session: ChatSessionInternal = {
id: model.id,
model,
isActive: true
isActive: true,
pinnedAgent
};
this._sessions.push(session);
this.setActiveSession(session.id, options);
@@ -179,8 +187,8 @@ export class ChatServiceImpl implements ChatService {
session.title = request.text;

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

const agent = this.getAgent(parsedRequest);
if (agent === undefined) {
const error = 'No ChatAgents available to handle request!';
this.logger.error(error);
@@ -242,7 +250,20 @@ export class ChatServiceImpl implements ChatService {
return this.getSession(sessionId)?.model.getRequest(requestId)?.response.cancel();
}

protected getAgent(parsedRequest: ParsedChatRequest): ChatAgent | undefined {
protected getAgent(parsedRequest: ParsedChatRequest, session: ChatSession): ChatAgent | undefined {
let agent = this.initialAgentSelection(parsedRequest);
if (this.pinChatAgent === false) {
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 initialAgentSelection(parsedRequest: ParsedChatRequest): ChatAgent | undefined {
const agentPart = this.getMentionedAgent(parsedRequest);
if (agentPart) {
return this.chatAgentService.getAgent(agentPart.agentId);