Skip to content

Add a suggestion to Architect to summarize the current session and continue with Coder #15512

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

Open
wants to merge 28 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
bd855c5
Agent prompt suggestions & chat summary
colin-grant-work Apr 3, 2025
54e2a55
Progress towards servicification
colin-grant-work Apr 15, 2025
86080b2
More service, more rendering, more handling
colin-grant-work Apr 17, 2025
a228f4e
Prompt customization available
colin-grant-work Apr 18, 2025
27f04ea
Style cleanup, etc.
colin-grant-work Apr 18, 2025
1d15f4b
Clean up output in case of no summaries
colin-grant-work Apr 18, 2025
8236ecb
Request agent suggestions if created with pinned agent
colin-grant-work Apr 18, 2025
5d24188
Bunch o renamings
colin-grant-work Apr 18, 2025
26fc311
Break out file storage into separate service
colin-grant-work Apr 21, 2025
6efee06
Add progress
colin-grant-work Apr 21, 2025
c81189f
Only offer existing summaries
colin-grant-work Apr 21, 2025
df731e1
Support non-ASCII labels
colin-grant-work Apr 21, 2025
c340df9
Explicit empty string
colin-grant-work Apr 22, 2025
9ebf3a5
No replacement if no session Id
colin-grant-work Apr 22, 2025
1291873
Add opening to variable system
colin-grant-work Apr 22, 2025
cc2ad68
The in-memory one can open it without a URI
colin-grant-work Apr 22, 2025
b87e3a1
Update packages/ai-chat/src/browser/task-context-variable.ts
colin-grant-work Apr 23, 2025
3e135ef
Update packages/ai-ide/src/browser/task-background-summary-variable.ts
colin-grant-work Apr 23, 2025
b54913b
getActiveSession to chat service
colin-grant-work Apr 23, 2025
46f9225
reorganize context adding on completion
colin-grant-work Apr 23, 2025
36f3b5e
Update prompt and move to MIT
colin-grant-work Apr 23, 2025
a2528e2
Update packages/ai-chat/src/common/chat-session-summary-agent-prompt.ts
colin-grant-work Apr 24, 2025
3c17316
Ensure current session available when suggestions requested
colin-grant-work Apr 24, 2025
3a54a20
Allow summarizing Architect sessions for Coder
JonasHelming Apr 24, 2025
70690c0
Update packages/ai-ide/src/common/architect-prompt-template.ts
JonasHelming Apr 24, 2025
98a1eef
Update packages/ai-ide/src/common/architect-prompt-template.ts
JonasHelming Apr 24, 2025
a50df41
Update packages/ai-ide/src/common/architect-prompt-template.ts
JonasHelming Apr 24, 2025
80208a5
Update packages/ai-ide/src/common/architect-prompt-template.ts
JonasHelming Apr 24, 2025
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 @@ -74,6 +74,7 @@
"license:check:review": "node scripts/check_3pp_licenses.js --review",
"lint": "lerna run lint",
"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
51 changes: 45 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,9 @@
// *****************************************************************************

import { inject, injectable } from '@theia/core/shared/inversify';
import { CommandRegistry, isOSX, nls, QuickInputButton, QuickInputService, QuickPickItem } from '@theia/core';
import { CommandRegistry, Emitter, isOSX, 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_NEW_WITH_TASK_CONTEXT, AI_CHAT_SHOW_CHATS_COMMAND, AI_CHAT_SUMMARIZE_CURRENT_SESSION, 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 @@ -28,6 +28,8 @@ import { formatDistance } from 'date-fns';
import * as locales from 'date-fns/locale';
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 { 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 @@ -38,6 +40,9 @@ export class AIChatContribution extends AbstractViewContribution<ChatViewWidget>
protected readonly chatService: ChatService;
@inject(QuickInputService)
protected readonly quickInputService: QuickInputService;
@inject(TaskContextService)
protected readonly taskContextService: TaskContextService;


protected static readonly RENAME_CHAT_BUTTON: QuickInputButton = {
iconClass: 'codicon-edit',
Expand Down Expand Up @@ -83,14 +88,34 @@ 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(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(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.getRequests().length
&& activeSession?.model.location === ChatAgentLocation.Panel
&& !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)
});
}

Expand Down Expand Up @@ -123,6 +148,14 @@ 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.' + AI_CHAT_SUMMARIZE_CURRENT_SESSION.id,
command: AI_CHAT_SUMMARIZE_CURRENT_SESSION.id,
onDidChange: sessionSummarizibilityChangedEmitter.event
});
}

protected async selectChat(sessionId?: string): Promise<void> {
Expand Down Expand Up @@ -211,6 +244,12 @@ 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 await this.taskContextService.summarize(activeSession);
}
}

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';
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={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;
}
}
34 changes: 28 additions & 6 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, 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 @@ -27,12 +27,14 @@ import { AIVariableResolutionRequest } from '@theia/ai-core';
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 { 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 @@ -69,6 +71,9 @@ export class AIChatInputWidget extends ReactWidget {
@inject(ChangeSetActionService)
protected readonly changeSetActionService: ChangeSetActionService;

@inject(OpenerService)
protected readonly openerService: OpenerService;

protected editorRef: MonacoEditor | undefined = undefined;
private editorReady = new Deferred<void>();

Expand All @@ -94,6 +99,10 @@ export class AIChatInputWidget extends ReactWidget {
set onDeleteChangeSetElement(deleteChangeSetElement: DeleteChangeSetElement) {
this._onDeleteChangeSetElement = deleteChangeSetElement;
}
private _onOpenContextELement: OpenContextElement;
set onOpenContextElement(opener: OpenContextElement) {
this._onOpenContextELement = opener;
}

protected onDisposeForChatModel = new DisposableCollection();
private _chatModel: ChatModel;
Expand Down Expand Up @@ -142,6 +151,7 @@ export class AIChatInputWidget extends ReactWidget {
onDeleteChangeSetElement={this._onDeleteChangeSetElement.bind(this)}
onAddContextElement={this.addContextElement.bind(this)}
onDeleteContextElement={this.deleteContextElement.bind(this)}
onOpenContextElement={this._onOpenContextELement.bind(this)}
context={this._chatModel.context.getVariables()}
chatModel={this._chatModel}
pinnedAgent={this._pinnedAgent}
Expand All @@ -157,6 +167,8 @@ export class AIChatInputWidget extends ReactWidget {
showPinnedAgent={this.configuration?.showPinnedAgent}
labelProvider={this.labelProvider}
actionService={this.changeSetActionService}
openerService={this.openerService}
suggestions={this._chatModel.suggestions}
/>
);
}
Expand Down Expand Up @@ -237,6 +249,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 @@ -249,6 +262,8 @@ interface ChatInputProperties {
showPinnedAgent?: boolean;
labelProvider: LabelProvider;
actionService: ChangeSetActionService;
openerService: OpenerService;
suggestions: readonly ChatSuggestion[]
}

const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInputProperties) => {
Expand Down Expand Up @@ -515,9 +530,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} >
return <div className='theia-ChatInput' onDragOver={props.onDragOver} onDrop={props.onDrop}>
{<ChatInputAgentSuggestions suggestions={props.suggestions} opener={props.openerService} />}
{changeSetUI?.elements &&
<ChangeSetBox changeSet={changeSetUI} />
}
Expand Down Expand Up @@ -685,7 +701,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 @@ -697,6 +718,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 @@ -727,7 +749,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
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ const MarkdownRender = ({ response, openerService }: { response: MarkdownChatRes
return <div ref={ref}></div>;
};

export interface DeclaredEventsEventListenerObject extends EventListenerObject {
handledEvents?: (keyof HTMLElementEventMap)[];
}

/**
* This hook uses markdown-it directly to render markdown.
* The reason to use markdown-it directly is that the MarkdownRenderer is
Expand All @@ -72,9 +76,17 @@ const MarkdownRender = ({ response, openerService }: { response: MarkdownChatRes
* @param markdown the string to render as markdown
* @param skipSurroundingParagraph whether to remove a surrounding paragraph element (default: false)
* @param openerService the service to handle link opening
* @param eventHandler `handleEvent` will be called by default for `click` events and additionally
* for all events enumerated in {@link DeclaredEventsEventListenerObject.handledEvents}. If `handleEvent` returns `true`,
* no additional handlers will be run for the event.
* @returns the ref to use in an element to render the markdown
*/
export const useMarkdownRendering = (markdown: string | MarkdownString, openerService: OpenerService, skipSurroundingParagraph: boolean = false) => {
export const useMarkdownRendering = (
markdown: string | MarkdownString,
openerService: OpenerService,
skipSurroundingParagraph: boolean = false,
eventHandler?: DeclaredEventsEventListenerObject
) => {
// null is valid in React
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement | null>(null);
Expand All @@ -98,6 +110,7 @@ export const useMarkdownRendering = (markdown: string | MarkdownString, openerSe

// intercept link clicks to use the Theia OpenerService instead of the default browser behavior
const handleClick = (event: MouseEvent) => {
if ((eventHandler?.handleEvent(event) as unknown) === true) {return; }
let target = event.target as HTMLElement;
while (target && target.tagName !== 'A') {
target = target.parentElement as HTMLElement;
Expand All @@ -112,7 +125,11 @@ export const useMarkdownRendering = (markdown: string | MarkdownString, openerSe
};

ref?.current?.addEventListener('click', handleClick);
return () => ref.current?.removeEventListener('click', handleClick);
eventHandler?.handledEvents?.forEach(eventType => eventType !== 'click' && ref?.current?.addEventListener(eventType, eventHandler));
return () => {
ref.current?.removeEventListener('click', handleClick);
eventHandler?.handledEvents?.forEach(eventType => eventType !== 'click' && ref?.current?.removeEventListener(eventType, eventHandler));
};
}, [markdownString, skipSurroundingParagraph, openerService]);

return ref;
Expand Down
Loading
Loading