diff --git a/CHANGELOG.md b/CHANGELOG.md index 913dce7b1602f..3c4244068b4e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ [Breaking Changes:](#breaking_changes_1.54.0) --> - [core] Updated AuthenticationService to handle multiple accounts per provider [#14149](https://github.com/eclipse-theia/theia/pull/14149) - Contributed on behalf of STMicroelectronics +- [ai] Add toolbar actions on chat nodes [#14181](https://github.com/eclipse-theia/theia/pull/14181) - Contributed on behalf of STMicroelectronics ## 1.53.0 - 08/29/2024 diff --git a/examples/api-samples/package.json b/examples/api-samples/package.json index 959f47f940556..ae2ecda230173 100644 --- a/examples/api-samples/package.json +++ b/examples/api-samples/package.json @@ -4,6 +4,7 @@ "version": "1.53.0", "description": "Theia - Example code to demonstrate Theia API", "dependencies": { + "@theia/ai-chat-ui": "1.53.0", "@theia/core": "1.53.0", "@theia/file-search": "1.53.0", "@theia/filesystem": "1.53.0", diff --git a/examples/api-samples/src/browser/api-samples-frontend-module.ts b/examples/api-samples/src/browser/api-samples-frontend-module.ts index fe6c53d05527c..fc41efb1bce03 100644 --- a/examples/api-samples/src/browser/api-samples-frontend-module.ts +++ b/examples/api-samples/src/browser/api-samples-frontend-module.ts @@ -30,6 +30,7 @@ import { rebindOVSXClientFactory } from '../common/vsx/sample-ovsx-client-factor import { bindSampleAppInfo } from './vsx/sample-frontend-app-info'; import { bindTestSample } from './test/sample-test-contribution'; import { bindSampleFileSystemCapabilitiesCommands } from './file-system/sample-file-system-capabilities'; +import { bindChatNodeToolbarActionContribution } from './chat/chat-node-toolbar-action-contribution'; export default new ContainerModule(( bind: interfaces.Bind, @@ -37,6 +38,7 @@ export default new ContainerModule(( isBound: interfaces.IsBound, rebind: interfaces.Rebind, ) => { + bindChatNodeToolbarActionContribution(bind); bindDynamicLabelProvider(bind); bindSampleUnclosableView(bind); bindSampleOutputChannelWithSeverity(bind); diff --git a/examples/api-samples/src/browser/chat/chat-node-toolbar-action-contribution.ts b/examples/api-samples/src/browser/chat/chat-node-toolbar-action-contribution.ts new file mode 100644 index 0000000000000..4494f753c81bd --- /dev/null +++ b/examples/api-samples/src/browser/chat/chat-node-toolbar-action-contribution.ts @@ -0,0 +1,41 @@ +// ***************************************************************************** +// Copyright (C) 2024 STMicroelectronics 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 { + ChatNodeToolbarActionContribution +} from '@theia/ai-chat-ui/lib/browser/chat-node-toolbar-action-contribution'; +import { + isResponseNode, + RequestNode, + ResponseNode +} from '@theia/ai-chat-ui/lib/browser/chat-tree-view'; +import { interfaces } from '@theia/core/shared/inversify'; + +export function bindChatNodeToolbarActionContribution(bind: interfaces.Bind): void { + bind(ChatNodeToolbarActionContribution).toDynamicValue(context => ({ + getToolbarActions: (args: RequestNode | ResponseNode) => { + if (isResponseNode(args)) { + return [{ + commandId: 'sample-command', + icon: 'codicon codicon-feedback', + tooltip: 'Example command' + }]; + } else { + return []; + } + } + })); +} diff --git a/examples/api-samples/tsconfig.json b/examples/api-samples/tsconfig.json index 8a1dfa8803322..551c17de9f91b 100644 --- a/examples/api-samples/tsconfig.json +++ b/examples/api-samples/tsconfig.json @@ -12,6 +12,9 @@ { "path": "../../dev-packages/ovsx-client" }, + { + "path": "../../packages/ai-chat-ui" + }, { "path": "../../packages/core" }, diff --git a/packages/ai-chat-ui/src/browser/ai-chat-ui-frontend-module.ts b/packages/ai-chat-ui/src/browser/ai-chat-ui-frontend-module.ts index 7c2c81f6a3e18..293d58c26c5c7 100644 --- a/packages/ai-chat-ui/src/browser/ai-chat-ui-frontend-module.ts +++ b/packages/ai-chat-ui/src/browser/ai-chat-ui-frontend-module.ts @@ -15,27 +15,28 @@ // ***************************************************************************** import { bindContributionProvider, CommandContribution, MenuContribution } from '@theia/core'; -import { bindViewContribution, FrontendApplicationContribution, WidgetFactory, } from '@theia/core/lib/browser'; +import { bindViewContribution, FrontendApplicationContribution, WidgetFactory } from '@theia/core/lib/browser'; import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { ContainerModule, interfaces } from '@theia/core/shared/inversify'; import { EditorManager } from '@theia/editor/lib/browser'; import '../../src/browser/style/index.css'; import { AIChatContribution } from './ai-chat-ui-contribution'; import { AIChatInputWidget } from './chat-input-widget'; -import { CodePartRenderer, CommandPartRenderer, HorizontalLayoutPartRenderer, MarkdownPartRenderer, ErrorPartRenderer, ToolCallPartRenderer } from './chat-response-renderer'; +import { ChatNodeToolbarActionContribution } from './chat-node-toolbar-action-contribution'; +import { ChatResponsePartRenderer } from './chat-response-part-renderer'; +import { CodePartRenderer, CommandPartRenderer, ErrorPartRenderer, HorizontalLayoutPartRenderer, MarkdownPartRenderer, ToolCallPartRenderer } from './chat-response-renderer'; import { AIEditorManager, AIEditorSelectionResolver, GitHubSelectionResolver, TextFragmentSelectionResolver, TypeDocSymbolSelectionResolver } from './chat-response-renderer/ai-editor-manager'; import { createChatViewTreeWidget } from './chat-tree-view'; import { ChatViewTreeWidget } from './chat-tree-view/chat-view-tree-widget'; -import { ChatViewLanguageContribution } from './chat-view-language-contribution'; import { ChatViewMenuContribution } from './chat-view-contribution'; +import { ChatViewLanguageContribution } from './chat-view-language-contribution'; import { ChatViewWidget } from './chat-view-widget'; import { ChatViewWidgetToolbarContribution } from './chat-view-widget-toolbar-contribution'; -import { ChatResponsePartRenderer } from './chat-response-part-renderer'; -export default new ContainerModule((bind, _ubind, _isBound, rebind) => { +export default new ContainerModule((bind, _unbind, _isBound, rebind) => { bindViewContribution(bind, AIChatContribution); bind(TabBarToolbarContribution).toService(AIChatContribution); @@ -81,6 +82,7 @@ export default new ContainerModule((bind, _ubind, _isBound, rebind) => { bind(FrontendApplicationContribution).to(ChatViewLanguageContribution).inSingletonScope(); + bindContributionProvider(bind, ChatNodeToolbarActionContribution); }); function bindChatViewWidget(bind: interfaces.Bind): void { diff --git a/packages/ai-chat-ui/src/browser/chat-node-toolbar-action-contribution.ts b/packages/ai-chat-ui/src/browser/chat-node-toolbar-action-contribution.ts new file mode 100644 index 0000000000000..1a52d4867e185 --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-node-toolbar-action-contribution.ts @@ -0,0 +1,63 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// 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 { RequestNode, ResponseNode } from './chat-tree-view'; + +export interface ChatNodeToolbarAction { + /** + * The command to execute when the item is selected. The handler will receive the `RequestNode` or `ResponseNode` as first argument. + */ + commandId: string; + /** + * Icon class name(s) for the item (e.g. 'codicon codicon-feedback'). + */ + icon: string; + /** + * Priority among the items. Can be negative. The smaller the number the left-most the item will be placed in the toolbar. It is `0` by default. + */ + priority?: number; + /** + * Optional tooltip for the item. + */ + tooltip?: string; +} + +/** + * Clients implement this interface if they want to contribute to the toolbar of chat nodes. + * + * ### Example + * ```ts + * bind(ChatNodeToolbarActionContribution).toDynamicValue(context => ({ + * getToolbarActions: (args: RequestNode | ResponseNode) => { + * if (isResponseNode(args)) { + * return [{ + * commandId: 'core.about', + * icon: 'codicon codicon-feedback', + * tooltip: 'Show about dialog on response nodes' + * }]; + * } else { + * return []; + * } + * } + * })); + * ``` + */ +export const ChatNodeToolbarActionContribution = Symbol('ChatNodeToolbarActionContribution'); +export interface ChatNodeToolbarActionContribution { + /** + * Returns the toolbar actions for the given node. + */ + getToolbarActions(node: RequestNode | ResponseNode): ChatNodeToolbarAction[]; +} diff --git a/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx b/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx index cdd9ec870436f..dc00ffcfacb38 100644 --- a/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx +++ b/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx @@ -14,11 +14,11 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { - ChatResponseContent, ChatAgentService, ChatModel, ChatProgressMessage, ChatRequestModel, + ChatResponseContent, ChatResponseModel, } from '@theia/ai-chat'; import { CommandRegistry, ContributionProvider } from '@theia/core'; @@ -40,13 +40,14 @@ import { inject, injectable, named, - postConstruct, + postConstruct } from '@theia/core/shared/inversify'; import * as React from '@theia/core/shared/react'; import { MarkdownRenderer } from '@theia/core/lib/browser/markdown-rendering/markdown-renderer'; -import { MarkdownWrapper } from '../chat-response-renderer/markdown-part-renderer'; +import { ChatNodeToolbarActionContribution } from '../chat-node-toolbar-action-contribution'; import { ChatResponsePartRenderer } from '../chat-response-part-renderer'; +import { MarkdownWrapper } from '../chat-response-renderer/markdown-part-renderer'; // TODO Instead of directly operating on the ChatRequestModel we could use an intermediate view model export interface RequestNode extends TreeNode { @@ -72,6 +73,9 @@ export class ChatViewTreeWidget extends TreeWidget { @inject(ContributionProvider) @named(ChatResponsePartRenderer) protected readonly chatResponsePartRenderers: ContributionProvider>; + @inject(ContributionProvider) @named(ChatNodeToolbarActionContribution) + protected readonly chatNodeToolbarActionContributions: ContributionProvider; + @inject(MarkdownRenderer) private renderer: MarkdownRenderer; @@ -257,16 +261,45 @@ export class ChatViewTreeWidget extends TreeWidget { ; } + private renderAgent(node: RequestNode | ResponseNode): React.ReactNode { const inProgress = isResponseNode(node) && !node.response.isComplete && !node.response.isCanceled && !node.response.isError; + const toolbarContributions = !inProgress + ? this.chatNodeToolbarActionContributions.getContributions() + .flatMap(c => c.getToolbarActions(node)) + .filter(action => this.commandRegistry.isEnabled(action.commandId, node)) + .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)) + : []; return

{this.getAgentLabel(node)}

{inProgress && Generating} +
+ {!inProgress && + toolbarContributions.length > 0 && + toolbarContributions.map(action => + { + e.stopPropagation(); + this.commandRegistry.executeCommand(action.commandId, node); + }} + onKeyDown={e => { + if (isEnterKey(e)) { + e.stopPropagation(); + this.commandRegistry.executeCommand(action.commandId, node); + } + }} + role='button' + > + )} +
; } + private getAgentLabel(node: RequestNode | ResponseNode): string { if (isRequestNode(node)) { // TODO find user name @@ -275,6 +308,7 @@ export class ChatViewTreeWidget extends TreeWidget { const agent = node.response.agentId ? this.chatAgentService.getAgent(node.response.agentId) : undefined; return agent?.name ?? 'AI'; } + private getAgentIconClassName(node: RequestNode | ResponseNode): string | undefined { if (isRequestNode(node)) { return codicon('account'); diff --git a/packages/ai-chat-ui/src/browser/style/index.css b/packages/ai-chat-ui/src/browser/style/index.css index 3f014cb6e9a26..13bee8bba2bc2 100644 --- a/packages/ai-chat-ui/src/browser/style/index.css +++ b/packages/ai-chat-ui/src/browser/style/index.css @@ -31,12 +31,13 @@ div:last-child > .theia-ChatNode { .theia-ChatNodeHeader { align-items: center; display: flex; + justify-content: space-between; + height: 24px; gap: 8px; width: 100%; } .theia-ChatNodeHeader .theia-AgentAvatar { - display: flex; pointer-events: none; user-select: none; font-size: 20px; @@ -91,6 +92,24 @@ div:last-child > .theia-ChatNode { font-weight: 600; } +.theia-ChatNode .theia-ChatNodeToolbar { + margin-left: auto; + line-height: 18px; +} +.theia-ChatNodeToolbar .theia-ChatNodeToolbarAction { + display: none; + align-items: center; + padding: 4px; + border-radius: 5px; +} +.theia-ChatNode:hover .theia-ChatNodeToolbar .theia-ChatNodeToolbarAction { + display: inline-block; +} +.theia-ChatNodeToolbar .theia-ChatNodeToolbarAction:hover { + cursor: pointer; + background-color: var(--theia-toolbar-hoverBackground); +} + .theia-ChatNode .rendered-markdown p { margin: 0 0 16px; }