Skip to content
Merged
Show file tree
Hide file tree
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
2 changes: 2 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"lint": "lerna run lint",
"lint:fix": "lerna run lint -- --fix",
"lint:clean": "rimraf .eslintcache",
"lint:fix": "lerna run lint -- --fix",
"preinstall": "node-gyp install",
"postinstall": "theia-patch && npm run -s compute-references && lerna run afterInstall",
"publish:latest": "lerna publish --exact --yes",
Expand Down
83 changes: 77 additions & 6 deletions packages/ai-chat-ui/src/browser/ai-chat-ui-contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@
// *****************************************************************************

import { inject, injectable } from '@theia/core/shared/inversify';
import { CommandRegistry, isOSX, nls, QuickInputButton, QuickInputService, QuickPickItem } from '@theia/core';
import { CommandRegistry, Emitter, isOSX, MessageService, nls, 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 {
AI_CHAT_NEW_CHAT_WINDOW_COMMAND,
AI_CHAT_SHOW_CHATS_COMMAND,
ChatCommands
} from './chat-view-commands';
import { 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';
Expand All @@ -30,6 +34,8 @@ import { AI_SHOW_SETTINGS_COMMAND } from '@theia/ai-core/lib/browser';
import { OPEN_AI_HISTORY_VIEW } from '@theia/ai-history/lib/browser/ai-history-contribution';
import { ChatNodeToolbarCommands } from './chat-node-toolbar-action-contribution';
import { isEditableRequestNode, type EditableRequestNode } from './chat-tree-view';
import { TASK_CONTEXT_VARIABLE } from '@theia/ai-chat/lib/browser/task-context-variable';
import { TaskContextService } from '@theia/ai-chat/lib/browser/task-context-service';

export const AI_CHAT_TOGGLE_COMMAND_ID = 'aiChat:toggle';

Expand All @@ -40,6 +46,10 @@ export class AIChatContribution extends AbstractViewContribution<ChatViewWidget>
protected readonly chatService: ChatService;
@inject(QuickInputService)
protected readonly quickInputService: QuickInputService;
@inject(TaskContextService)
protected readonly taskContextService: TaskContextService;
@inject(MessageService)
protected readonly messageService: MessageService;

protected static readonly RENAME_CHAT_BUTTON: QuickInputButton = {
iconClass: 'codicon-edit',
Expand Down Expand Up @@ -85,14 +95,52 @@ export class AIChatContribution extends AbstractViewContribution<ChatViewWidget>
})
});
registry.registerCommand(AI_CHAT_NEW_CHAT_WINDOW_COMMAND, {
execute: () => this.chatService.createSession(ChatAgentLocation.Panel, { focus: true }),
isEnabled: widget => this.withWidget(widget, () => true),
execute: () => this.openView().then(() => this.chatService.createSession(ChatAgentLocation.Panel, { focus: true })),
isVisible: widget => this.withWidget(widget, () => true),
});
registry.registerCommand(ChatCommands.AI_CHAT_NEW_WITH_TASK_CONTEXT, {
execute: async () => {
const activeSession = this.chatService.getActiveSession();
const id = await this.summarizeActiveSession();
if (!id || !activeSession) { return; }
const newSession = this.chatService.createSession(ChatAgentLocation.Panel, { focus: true }, activeSession.pinnedAgent);
const summaryVariable = { variable: TASK_CONTEXT_VARIABLE, arg: id };
newSession.model.context.addVariables(summaryVariable);
},
isVisible: () => false
});
registry.registerCommand(ChatCommands.AI_CHAT_SUMMARIZE_CURRENT_SESSION, {
execute: async () => this.summarizeActiveSession(),
isVisible: widget => {
if (widget && !this.withWidget(widget)) { return false; }
const activeSession = this.chatService.getActiveSession();
return activeSession?.model.location === ChatAgentLocation.Panel
&& !this.taskContextService.hasSummary(activeSession);
},
isEnabled: widget => {
if (widget && !this.withWidget(widget)) { return false; }
const activeSession = this.chatService.getActiveSession();
return activeSession?.model.location === ChatAgentLocation.Panel
&& !activeSession.model.isEmpty()
&& !this.taskContextService.hasSummary(activeSession);
}
});
registry.registerCommand(ChatCommands.AI_CHAT_OPEN_SUMMARY_FOR_CURRENT_SESSION, {
execute: async () => {
const id = await this.summarizeActiveSession();
if (!id) { return; }
await this.taskContextService.open(id);
},
isVisible: widget => {
if (widget && !this.withWidget(widget)) { return false; }
const activeSession = this.chatService.getActiveSession();
return !!activeSession && this.taskContextService.hasSummary(activeSession);
}
});
registry.registerCommand(AI_CHAT_SHOW_CHATS_COMMAND, {
execute: () => this.selectChat(),
isEnabled: widget => this.withWidget(widget, () => true) && this.chatService.getSessions().length > 1,
isVisible: widget => this.withWidget(widget, () => true)
isEnabled: widget => this.withWidget(widget) && this.chatService.getSessions().length > 1,
isVisible: widget => this.withWidget(widget)
});
registry.registerCommand(ChatNodeToolbarCommands.EDIT, {
isEnabled: node => isEditableRequestNode(node) && !node.request.isEditing,
Expand Down Expand Up @@ -139,6 +187,19 @@ export class AIChatContribution extends AbstractViewContribution<ChatViewWidget>
priority: 1,
isVisible: widget => this.withWidget(widget),
});
const sessionSummarizibilityChangedEmitter = new Emitter<void>();
this.taskContextService.onDidChange(() => sessionSummarizibilityChangedEmitter.fire());
this.chatService.onSessionEvent(event => event.type === 'activeChange' && sessionSummarizibilityChangedEmitter.fire());
registry.registerItem({
id: 'chat-view.' + ChatCommands.AI_CHAT_SUMMARIZE_CURRENT_SESSION.id,
command: ChatCommands.AI_CHAT_SUMMARIZE_CURRENT_SESSION.id,
onDidChange: sessionSummarizibilityChangedEmitter.event
});
registry.registerItem({
id: 'chat-view.' + ChatCommands.AI_CHAT_OPEN_SUMMARY_FOR_CURRENT_SESSION.id,
command: ChatCommands.AI_CHAT_OPEN_SUMMARY_FOR_CURRENT_SESSION.id,
onDidChange: sessionSummarizibilityChangedEmitter.event
});
}

protected async selectChat(sessionId?: string): Promise<void> {
Expand Down Expand Up @@ -227,6 +288,16 @@ export class AIChatContribution extends AbstractViewContribution<ChatViewWidget>
canExtractChatView(chatView: ChatViewWidget): boolean {
return !chatView.secondaryWindow;
}

protected async summarizeActiveSession(): Promise<string | undefined> {
const activeSession = this.chatService.getActiveSession();
if (!activeSession) { return; }
return this.taskContextService.summarize(activeSession).catch(err => {
console.warn('Error while summarizing session:', err);
this.messageService.error('Unable to summarize current session. Please confirm that the summary agent is not disabled.');
return undefined;
});
}
}

function getDateFnsLocale(): locales.Locale {
Expand Down
85 changes: 85 additions & 0 deletions packages/ai-chat-ui/src/browser/chat-input-agent-suggestions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************

import * as React from '@theia/core/shared/react';
import { DeclaredEventsEventListenerObject, useMarkdownRendering } from './chat-response-renderer/markdown-part-renderer';
import { OpenerService } from '@theia/core/lib/browser';
import { ChatSuggestion, ChatSuggestionCallback } from '@theia/ai-chat';
import { MarkdownString } from '@theia/core/lib/common/markdown-rendering';

interface ChatInputAgentSuggestionsProps {
suggestions: readonly ChatSuggestion[];
opener: OpenerService;
}

function getText(suggestion: ChatSuggestion): string {
if (typeof suggestion === 'string') { return suggestion; }
if ('value' in suggestion) { return suggestion.value; }
if (typeof suggestion.content === 'string') { return suggestion.content; }
return suggestion.content.value;
}

function getContent(suggestion: ChatSuggestion): string | MarkdownString {
if (typeof suggestion === 'string') { return suggestion; }
if ('value' in suggestion) { return suggestion; }
return suggestion.content;
}

export const ChatInputAgentSuggestions: React.FC<ChatInputAgentSuggestionsProps> = ({ suggestions, opener }) => (
!!suggestions?.length && <div className="chat-agent-suggestions">
{suggestions.map(suggestion => <ChatInputAgentSuggestion
key={getText(suggestion)}
suggestion={suggestion}
opener={opener}
handler={ChatSuggestionCallback.is(suggestion) ? new ChatSuggestionClickHandler(suggestion) : undefined}
/>)}
</div>
);

interface ChatInputAgestSuggestionProps {
suggestion: ChatSuggestion;
opener: OpenerService;
handler?: DeclaredEventsEventListenerObject;
}

const ChatInputAgentSuggestion: React.FC<ChatInputAgestSuggestionProps> = ({ suggestion, opener, handler }) => {
const ref = useMarkdownRendering(getContent(suggestion), opener, true, handler);
return <div className="chat-agent-suggestion" style={(!handler || ChatSuggestionCallback.containsCallbackLink(suggestion)) ? undefined : { cursor: 'pointer' }} ref={ref} />;
};

class ChatSuggestionClickHandler implements DeclaredEventsEventListenerObject {
constructor(protected readonly suggestion: ChatSuggestionCallback) { }
handleEvent(event: Event): boolean {
const { target, currentTarget } = event;
if (event.type !== 'click' || !(target instanceof Element)) { return false; }
const link = target.closest('a[href^="_callback"]');
if (link) {
this.suggestion.callback();
return true;
}
if (!(currentTarget instanceof Element)) {
this.suggestion.callback();
return true;
}
const containedLink = currentTarget.querySelector('a[href^="_callback"]');
// Whole body should count.
if (!containedLink) {
this.suggestion.callback();
return true;
}
return false;
}
}
37 changes: 32 additions & 5 deletions packages/ai-chat-ui/src/browser/chat-input-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ChangeSet, ChatAgent, ChatChangeEvent, ChatModel, ChatRequestModel } from '@theia/ai-chat';
import { ChangeSet, ChatAgent, ChatChangeEvent, ChatModel, ChatRequestModel, ChatService, ChatSuggestion } from '@theia/ai-chat';
import { Disposable, DisposableCollection, InMemoryResources, URI, nls } from '@theia/core';
import { ContextMenuRenderer, LabelProvider, Message, ReactWidget } from '@theia/core/lib/browser';
import { ContextMenuRenderer, LabelProvider, Message, OpenerService, ReactWidget } from '@theia/core/lib/browser';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { inject, injectable, optional, postConstruct } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
Expand All @@ -28,12 +28,14 @@ import { FrontendVariableService } from '@theia/ai-core/lib/browser';
import { ContextVariablePicker } from './context-variable-picker';
import { ChangeSetActionRenderer, ChangeSetActionService } from './change-set-actions/change-set-action-service';
import { ChangeSetDecoratorService } from '@theia/ai-chat/lib/browser/change-set-decorator-service';
import { ChatInputAgentSuggestions } from './chat-input-agent-suggestions';

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;
type OpenContextElement = (request: AIVariableResolutionRequest) => unknown;

export const AIChatInputConfiguration = Symbol('AIChatInputConfiguration');
export interface AIChatInputConfiguration {
Expand Down Expand Up @@ -74,6 +76,12 @@ export class AIChatInputWidget extends ReactWidget {
@inject(ChangeSetDecoratorService)
protected readonly changeSetDecoratorService: ChangeSetDecoratorService;

@inject(OpenerService)
protected readonly openerService: OpenerService;

@inject(ChatService)
protected readonly chatService: ChatService;

protected editorRef: SimpleMonacoEditor | undefined = undefined;
protected readonly editorReady = new Deferred<void>();

Expand Down Expand Up @@ -157,6 +165,7 @@ export class AIChatInputWidget extends ReactWidget {
onAddContextElement={this.addContextElement.bind(this)}
onDeleteContextElement={this.deleteContextElement.bind(this)}
context={this.getContext()}
onOpenContextElement={this.openContextElement.bind(this)}
chatModel={this._chatModel}
pinnedAgent={this._pinnedAgent}
editorProvider={this.editorProvider}
Expand All @@ -175,6 +184,8 @@ export class AIChatInputWidget extends ReactWidget {
actionService={this.changeSetActionService}
decoratorService={this.changeSetDecoratorService}
initialValue={this._initialValue}
openerService={this.openerService}
suggestions={this._chatModel.suggestions}
/>
);
}
Expand Down Expand Up @@ -213,6 +224,12 @@ export class AIChatInputWidget extends ReactWidget {
});
}

protected async openContextElement(request: AIVariableResolutionRequest): Promise<void> {
const session = this.chatService.getSessions().find(candidate => candidate.model.id === this._chatModel.id);
const context = { session };
await this.variableService.open(request, context);
}

public setEnabled(enabled: boolean): void {
this.isEnabled = enabled;
this.update();
Expand Down Expand Up @@ -259,6 +276,7 @@ interface ChatInputProperties {
onDeleteChangeSetElement: (sessionId: string, index: number) => void;
onAddContextElement: () => void;
onDeleteContextElement: (index: number) => void;
onOpenContextElement: OpenContextElement;
context?: readonly AIVariableResolutionRequest[];
isEnabled?: boolean;
chatModel: ChatModel;
Expand All @@ -275,6 +293,8 @@ interface ChatInputProperties {
actionService: ChangeSetActionService;
decoratorService: ChangeSetDecoratorService;
initialValue?: string;
openerService: OpenerService;
suggestions: readonly ChatSuggestion[]
}

const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInputProperties) => {
Expand Down Expand Up @@ -570,9 +590,10 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
disabled: isInputEmpty || !props.isEnabled
}];

const contextUI = buildContextUI(props.context, props.labelProvider, props.onDeleteContextElement);
const contextUI = buildContextUI(props.context, props.labelProvider, props.onDeleteContextElement, props.onOpenContextElement);

return <div className='theia-ChatInput' onDragOver={props.onDragOver} onDrop={props.onDrop} >
{<ChatInputAgentSuggestions suggestions={props.suggestions} opener={props.openerService} />}
{props.showChangeSet && changeSetUI?.elements &&
<ChangeSetBox changeSet={changeSetUI} />
}
Expand Down Expand Up @@ -746,7 +767,12 @@ function getLatestRequest(chatModel: ChatModel): ChatRequestModel | undefined {
return requests.length > 0 ? requests[requests.length - 1] : undefined;
}

function buildContextUI(context: readonly AIVariableResolutionRequest[] | undefined, labelProvider: LabelProvider, onDeleteContextElement: (index: number) => void): ChatContextUI {
function buildContextUI(
context: readonly AIVariableResolutionRequest[] | undefined,
labelProvider: LabelProvider,
onDeleteContextElement: (index: number) => void,
onOpen: OpenContextElement
): ChatContextUI {
if (!context) {
return { context: [] };
}
Expand All @@ -758,6 +784,7 @@ function buildContextUI(context: readonly AIVariableResolutionRequest[] | undefi
additionalInfo: labelProvider.getDetails(element),
details: labelProvider.getLongName(element),
delete: () => onDeleteContextElement(index),
open: () => onOpen(element)
}))
};
}
Expand Down Expand Up @@ -788,7 +815,7 @@ const ChatContext: React.FunctionComponent<ChatContextUI> = ({ context }) => (
{element.additionalInfo}
</span>
</div>
<span className="codicon codicon-close action" title={nls.localizeByDefault('Delete')} onClick={() => element.delete()} />
<span className="codicon codicon-close action" title={nls.localizeByDefault('Delete')} onClick={e => { e.stopPropagation(); element.delete(); }} />
</li>
))}
</ul>
Expand Down
Loading
Loading