// ***************************************************************************** // Copyright (C) 2026 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 { ChatAgentService, ChatRequestModel, ChatResponseContent, ChatSession, ChatSessionMetadata, ChatSessionStatus, ErrorChatResponseContent, FormattedProviderError, formatProviderError, ThinkingChatResponseContent } from '@theia/ai-chat'; import { MarkdownRenderer } from '@theia/core/lib/browser/markdown-rendering/markdown-renderer'; import { nls } from '@theia/core/lib/common/nls'; import { Disposable, DisposableCollection } from '@theia/core'; import { formatTimeAgo } from '@theia/ai-chat-ui/lib/browser/chat-date-utils'; const TOOLTIP_SNIPPET_MAX_LENGTH = 1000; /** * A tooltip element paired with a {@link Disposable} that releases resources held by its * rendered content (e.g. a markdown render result). Dispose it when the tooltip is torn down. */ export interface SessionTooltip extends Disposable { element: HTMLElement; } /** Read an error message from a completed-with-error response, if any. */ function getResponseErrorMessage(response: ChatRequestModel['response']): string | undefined { if (response.errorObject?.message) { return response.errorObject.message; } const errorPart = response.response.content.find(ErrorChatResponseContent.is); return errorPart?.asDisplayString?.(); } /** * Build a DOM fragment that renders a {@link FormattedProviderError} for the tooltip. * Details are intentionally omitted: the hover popup is not interactive, so a *
expander wouldn't work. The full payload is available in the chat output. */ function renderFormattedProviderError(error: FormattedProviderError): HTMLElement { const wrapper = document.createElement('div'); wrapper.className = 'theia-chat-session-tooltip-error'; const prefix = document.createElement('span'); prefix.className = 'theia-chat-session-tooltip-error-prefix'; prefix.textContent = error.status ? `${nls.localizeByDefault('Error')} ${error.status}:` : `${nls.localizeByDefault('Error')}:`; wrapper.appendChild(prefix); const headline = error.message.length > TOOLTIP_SNIPPET_MAX_LENGTH ? error.message.substring(0, TOOLTIP_SNIPPET_MAX_LENGTH) + '…' : error.message; wrapper.appendChild(document.createTextNode(' ' + headline)); return wrapper; } /** Collect display text from response content, excluding thinking parts. */ function responseToTooltipString(content: ChatResponseContent[]): string { return content .filter(c => !ThinkingChatResponseContent.is(c)) .map(c => { if (ChatResponseContent.hasAsString(c)) { return c.asString(); } return undefined; }) .filter((text): text is string => text !== undefined && text !== '') .join('\n\n'); } function addDefinitionEntry(definitionList: HTMLDListElement, term: string, detail: string): void { const termElement = document.createElement('dt'); termElement.textContent = term; definitionList.appendChild(termElement); const detailElement = document.createElement('dd'); detailElement.textContent = detail; definitionList.appendChild(detailElement); } export function buildSessionTooltip( session: ChatSession, metadata: ChatSessionMetadata, agentService: ChatAgentService, markdownRenderer: MarkdownRenderer, isUnread: boolean, descendantNeedsAttention: boolean = false ): SessionTooltip { const toDispose = new DisposableCollection(); const requests = session.model.getRequests(); const lastRequest = requests.at(-1); const status = session.model.status; const container = document.createElement('div'); container.className = 'theia-chat-session-tooltip'; // Waiting on the user takes precedence over the generic running badge: it signals that the // user, not the agent, needs to act. if (status === 'awaitingApproval') { const badge = document.createElement('div'); badge.className = 'theia-chat-session-badge-attention-tooltip'; badge.textContent = nls.localize('theia/ai/ide/requiresApproval', 'Requires your approval'); container.appendChild(badge); } else if (status === 'awaitingInput') { const badge = document.createElement('div'); badge.className = 'theia-chat-session-badge-attention-tooltip'; badge.textContent = nls.localize('theia/ai/ide/waitingForInput', 'Waiting for your input'); container.appendChild(badge); } else if (descendantNeedsAttention) { // The session itself is not awaiting the user, but one of its delegated children is; // surface that instead of the generic running badge so the reason is clear. const badge = document.createElement('div'); badge.className = 'theia-chat-session-badge-attention-tooltip'; badge.textContent = nls.localize('theia/ai/ide/childInteractionNeeded', 'A delegated session needs your attention'); container.appendChild(badge); } else if (ChatSessionStatus.isInProgress(status)) { const badge = document.createElement('div'); badge.className = 'theia-chat-session-badge-running-tooltip'; badge.textContent = nls.localizeByDefault('Running'); container.appendChild(badge); } else if (status === 'failed') { const badge = document.createElement('div'); badge.className = 'theia-chat-session-badge-error-tooltip'; badge.textContent = nls.localizeByDefault('Error'); container.appendChild(badge); } else if (isUnread) { const badge = document.createElement('div'); badge.className = 'theia-chat-session-badge-unread-tooltip'; badge.textContent = nls.localize('theia/ai/ide/tooltip/unread', 'Unread'); container.appendChild(badge); } if (lastRequest) { const lastResponse = lastRequest.response; const errorText = status === 'failed' ? getResponseErrorMessage(lastResponse) : undefined; if (errorText) { const label = document.createElement('div'); label.className = 'theia-chat-session-tooltip-label'; label.textContent = nls.localize('theia/ai/ide/tooltip/errorMessage', 'Error message'); container.appendChild(label); container.appendChild(renderFormattedProviderError(formatProviderError(errorText))); const separator = document.createElement('hr'); container.appendChild(separator); } else { const messageText = lastResponse.isComplete ? (responseToTooltipString(lastResponse.response.content) || undefined) : (lastRequest.request.text || undefined); if (messageText) { const snippet = messageText.length > TOOLTIP_SNIPPET_MAX_LENGTH ? messageText.substring(0, TOOLTIP_SNIPPET_MAX_LENGTH) + '…' : messageText; const label = document.createElement('div'); label.className = 'theia-chat-session-tooltip-label'; label.textContent = nls.localize('theia/ai/ide/tooltip/lastMessage', 'Last message'); container.appendChild(label); const snippetElement = document.createElement('div'); snippetElement.className = 'theia-chat-session-tooltip-snippet'; const rendered = markdownRenderer.render({ value: snippet }); toDispose.push(rendered); snippetElement.appendChild(rendered.element); container.appendChild(snippetElement); const separator = document.createElement('hr'); container.appendChild(separator); } } } const definitionList = document.createElement('dl'); if (lastRequest) { const agentId = lastRequest.response.agentId ?? requests.findLast(r => r.response.agentId)?.response.agentId; if (agentId) { const agentName = agentService.getAgent(agentId)?.name ?? agentId; addDefinitionEntry(definitionList, nls.localizeByDefault('Agent'), '@' + agentName); } } const count = requests.length; const exchangeLabel = count === 1 ? nls.localize('theia/ai/ide/tooltip/oneExchange', '1 exchange') : nls.localize('theia/ai/ide/tooltip/multipleExchanges', '{0} exchanges', count); addDefinitionEntry(definitionList, nls.localizeByDefault('Messages'), exchangeLabel); const date = session.lastInteraction ?? new Date(metadata.saveDate); addDefinitionEntry(definitionList, nls.localize('theia/ai/ide/tooltip/lastActivity', 'Last activity'), date.toLocaleString()); container.appendChild(definitionList); return { element: container, dispose: () => toDispose.dispose() }; } /** * Build a tooltip for a session that hasn't been loaded yet (i.e. a restored / persisted entry * shown on the home view). Avoids any restore I/O so hover does not promote the item to Active. */ export function buildRestoredSessionTooltip( metadata: ChatSessionMetadata, agentService: ChatAgentService, descendantNeedsAttention: boolean = false ): SessionTooltip { const container = document.createElement('div'); container.className = 'theia-chat-session-tooltip'; const badge = document.createElement('div'); if (descendantNeedsAttention) { // A persisted parent whose delegated child needs the user; show that rather than "Restored". badge.className = 'theia-chat-session-badge-attention-tooltip'; badge.textContent = nls.localize('theia/ai/ide/childInteractionNeeded', 'A delegated session needs your attention'); } else { badge.className = 'theia-chat-session-badge-restored-tooltip'; badge.textContent = nls.localize('theia/ai/ide/restoredSession', 'Restored session'); } container.appendChild(badge); const definitionList = document.createElement('dl'); if (metadata.pinnedAgentId) { const agentName = agentService.getAgent(metadata.pinnedAgentId)?.name ?? metadata.pinnedAgentId; addDefinitionEntry(definitionList, nls.localizeByDefault('Agent'), '@' + agentName); } const date = new Date(metadata.saveDate); addDefinitionEntry(definitionList, nls.localize('theia/ai/ide/tooltip/lastActivity', 'Last activity'), `${date.toLocaleString()} (${formatTimeAgo(metadata.saveDate)})`); container.appendChild(definitionList); // No rendered content to release here, but return a uniform type so callers dispose unconditionally. return { element: container, dispose: () => { } }; }