diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts index 04a51e71ef6..788af39eecd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -116,7 +116,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen * Extracts subagent info (description, agentName, prompt) from a tool invocation. */ private static extractSubagentInfo(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): { description: string; agentName: string | undefined; prompt: string | undefined; modelName: string | undefined } { - const defaultDescription = localize('chat.subagent.defaultDescription', 'Running subagent...'); + const defaultDescription = localize('chat.subagent.defaultDescription', 'Running subagent'); // Only parent subagent tools contain the full subagent info if (!ChatSubagentContentPart.isParentSubagentTool(toolInvocation)) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index fb024c6afb5..a1a053d2968 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -34,9 +34,11 @@ import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; import { ChatMessageRole, ILanguageModelsService } from '../../../common/languageModels.js'; import './media/chatThinkingContent.css'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { extractImagesFromToolInvocationOutputDetails } from '../../../common/chatImageExtraction.js'; import { IChatCollapsibleIODataPart } from './chatToolInputOutputContentPart.js'; import { ChatThinkingExternalResourceWidget } from './chatThinkingExternalResourcesWidget.js'; +import { LocalChatSessionUri, chatSessionResourceToId } from '../../../common/model/chatUri.js'; import { IEditSessionDiffStats } from '../../../common/editing/chatEditingService.js'; @@ -140,6 +142,10 @@ interface ILazyThinkingItem { type ILazyItem = ILazyToolItem | ILazyThinkingItem; const THINKING_SCROLL_MAX_HEIGHT = 200; +const TITLE_CACHE_STORAGE_KEY = 'chat.thinkingTitleCache'; +const TITLE_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +const TITLE_CACHE_MAX_ENTRIES = 1000; + const enum WorkingMessageCategory { Thinking = 'thinking', Terminal = 'terminal', @@ -284,6 +290,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen @IChatMarkdownAnchorService private readonly chatMarkdownAnchorService: IChatMarkdownAnchorService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @IHoverService hoverService: IHoverService, + @IStorageService private readonly storageService: IStorageService, ) { const initialText = extractTextFromPart(content); const extractedTitle = extractTitleFromThinkingContent(initialText) @@ -897,6 +904,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen return; } + // Reuse any existing generated title from tool invocations or thinking parts. const existingTitle = this.toolInvocations.find(t => t.generatedTitle)?.generatedTitle ?? this.allThinkingParts.find(t => t.generatedTitle)?.generatedTitle; if (existingTitle) { @@ -907,6 +915,27 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen return; } + // Only check the persisted cache when re-rendering + // (all tool invocations are serialized), not during live streaming. + const allSerialized = this.toolInvocations.length > 0 + && this.toolInvocations.every(t => t.kind === 'toolInvocationSerialized'); + if (allSerialized) { + // Fallback: check the persisted title cache using the last tool call (non-local sessions only) + if (!LocalChatSessionUri.isLocalSession(this.element.sessionResource)) { + const lastToolInvocation = this.toolInvocations[this.toolInvocations.length - 1]; + if (lastToolInvocation) { + const cachedTitle = this.getCachedTitle(lastToolInvocation.toolCallId); + if (cachedTitle) { + this.currentTitle = cachedTitle; + this.content.generatedTitle = cachedTitle; + this.setGeneratedTitleOnAllParts(cachedTitle); + this.setFinalizedTitle(cachedTitle); + return; + } + } + } + } + // case where we only have one item (tool or edit) in the thinking container and no thinking parts, we want to move it back to its original position if (this.toolInvocationCount === 1 && this.hookCount === 0 && this.currentThinkingValue.trim() === '') { // If singleItemInfo wasn't set (item was lazy/deferred), materialize it now @@ -966,6 +995,55 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } } + private loadTitleCache(): Record { + return this.storageService.getObject>(TITLE_CACHE_STORAGE_KEY, StorageScope.PROFILE) ?? {}; + } + + private saveTitleCache(cache: Record): void { + if (Object.keys(cache).length === 0) { + this.storageService.remove(TITLE_CACHE_STORAGE_KEY, StorageScope.PROFILE); + } else { + this.storageService.store(TITLE_CACHE_STORAGE_KEY, JSON.stringify(cache), StorageScope.PROFILE, StorageTarget.MACHINE); + } + } + + private getTitleCacheKey(toolCallId: string): string { + return `${chatSessionResourceToId(this.element.sessionResource)}:${toolCallId}`; + } + + private getCachedTitle(toolCallId: string): string | undefined { + const entry = this.loadTitleCache()[this.getTitleCacheKey(toolCallId)]; + if (!entry || (Date.now() - entry.storedAt) > TITLE_CACHE_TTL_MS) { + return undefined; + } + return entry.title; + } + + private setCachedTitle(toolCallId: string, title: string): void { + const cache = this.loadTitleCache(); + const now = Date.now(); + + // Evict expired entries on write + for (const key of Object.keys(cache)) { + if ((now - cache[key].storedAt) > TITLE_CACHE_TTL_MS) { + delete cache[key]; + } + } + + cache[this.getTitleCacheKey(toolCallId)] = { title, storedAt: now }; + + // Cap size by dropping oldest entries + const keys = Object.keys(cache); + if (keys.length > TITLE_CACHE_MAX_ENTRIES) { + const sorted = keys.sort((a, b) => cache[a].storedAt - cache[b].storedAt); + for (let i = 0; i < sorted.length - TITLE_CACHE_MAX_ENTRIES; i++) { + delete cache[sorted[i]]; + } + } + + this.saveTitleCache(cache); + } + private async generateTitleViaLLM(): Promise { const cts = new CancellationTokenSource(); const timeout = setTimeout(() => cts.cancel(), 5000); @@ -1123,6 +1201,15 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): this.setFinalizedTitle(generatedTitle); this.content.generatedTitle = generatedTitle; this.setGeneratedTitleOnAllParts(generatedTitle); + + // Persist to storage for non-local sessions only + if (!LocalChatSessionUri.isLocalSession(this.element.sessionResource)) { + const lastTool = this.toolInvocations[this.toolInvocations.length - 1]; + if (lastTool) { + this.setCachedTitle(lastTool.toolCallId, generatedTitle); + } + } + return; } } catch (error) { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts index f141f1a1830..93339e79244 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts @@ -144,7 +144,7 @@ suite('ChatSubagentContentPart', () => { prompt: 'Test prompt' }, originMessage: undefined, - invocationMessage: options.invocationMessage ?? 'Running subagent...', + invocationMessage: options.invocationMessage ?? 'Running subagent', pastTenseMessage: undefined, source: ToolDataSource.Internal, toolId: options.toolId ?? RunSubagentTool.Id, @@ -180,7 +180,7 @@ suite('ChatSubagentContentPart', () => { result: 'Test result text' }, originMessage: undefined, - invocationMessage: 'Running subagent...', + invocationMessage: 'Running subagent', pastTenseMessage: undefined, resultDetails: undefined, isConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded },