From 1d774709d7de76df16536f588a13ae9563ca5bcc Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:16:30 -0700 Subject: [PATCH 1/3] Agent host migration improvements --- .../platform/agentHost/node/agentService.ts | 25 +++++++++++---- .../browser/actions/chatContinueInAction.ts | 6 ++++ .../agentHost/agentHostSessionHandler.ts | 18 ++++++++++- .../importLocalConversationToAgentSession.ts | 16 ++++++++-- .../chatSessions/chatSessions.contribution.ts | 10 ++++-- ...ortLocalConversationToAgentSession.test.ts | 32 +++++++++++++++++++ 6 files changed, 93 insertions(+), 14 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index e5000dfb3a728b..d8aa414eb66365 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -931,6 +931,16 @@ export class AgentService extends Disposable implements IAgentService { if (initialCustomizations && initialCustomizations.length > 0) { state.customizations = [...initialCustomizations]; } + + // Refine the placeholder title into one generated from the imported + // conversation, mirroring forks. Imports seed pre-existing turns, so + // the normal first-message title generation never fires; without this + // the session would keep showing the raw first-message clip while + // sibling sessions show clean generated titles — making imports look + // like a different kind of session. + if (importedTurns.length > 0) { + this._sideEffects.generateForkedTitle(summary.resource, undefined, importedTurns, importedTitle); + } } else { // Provisional sessions defer the `sessionAdded` notification and // the `SessionReady` lifecycle transition until the agent fires @@ -1199,19 +1209,20 @@ export class AgentService extends Disposable implements IAgentService { } /** - * Derives a title for an imported session from its first user turn (imports - * seed pre-existing turns, so the normal first-message title generation - * never fires). Falls back to a generic label for an empty import. + * Derives a placeholder title for an imported session from its first user + * turn (imports seed pre-existing turns, so the normal first-message title + * generation never fires). Deliberately unprefixed: an imported session is a + * continuation of the source chat, not a distinct kind of session, so it + * should read like any other. The placeholder is later refined into a + * generated title (see the `importConversation` branch in `createSession`). */ private _buildImportedTitle(turns: readonly Turn[]): string { - const importedPrefix = localize('agentHost.importedTitlePrefix', "Imported: "); const firstText = turns.find(t => t.message?.text?.trim())?.message.text.trim(); if (!firstText) { - return localize('agentHost.importedSessionFallback', "Imported Session"); + return ''; } const MAX = 60; - const clipped = firstText.length > MAX ? `${firstText.slice(0, MAX)}...` : firstText; - return `${importedPrefix}${clipped}`; + return firstText.length > MAX ? `${firstText.slice(0, MAX)}...` : firstText; } private _buildInitialSummary(provider: IAgent, session: URI, config: IAgentCreateSessionConfig | undefined, created: { project?: { uri: URI; displayName: string }; workingDirectory?: URI }, title: string): SessionSummary { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts index 2c3a92e105b110..cfa644227ba24a 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts @@ -595,6 +595,12 @@ export class CreateRemoteAgentJobAction { type: continuationTargetType, displayName: continuationTarget.displayName, position: isSidebar ? ChatSessionPosition.Sidebar : ChatSessionPosition.Editor, + // Replace the source chat editor in place so switching harness + // feels like the same chat continues rather than opening a new + // tab. The source (local) session stays in chat history and is + // recoverable. The sidebar path already swaps in place via + // `loadSession`, so it needs no replacement. + replaceEditor: !isSidebar, }, { prompt: handoffPrompt, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 8aa4be88769af6..ac13b5d30a699e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -1175,6 +1175,15 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return {}; } + // Creating/resuming an agent-host session can take several seconds on + // first use (CLI spawn, git worktree, plugin snapshot) — work done below + // before any turn progress is emitted. Show a shimmering status only if the + // turn is slow to start, cancelled as soon as real progress streams, so + // established sessions' fast turns never flash it. + const preparingStatus = disposableTimeout(() => { + progress([{ kind: 'progressMessage', content: new MarkdownString(localize('agentHost.preparingSession', "Preparing session…")), shimmer: true }]); + }, 500); + const resolvedSession = this._resolveSessionUri(request.sessionResource); const sessionKey = resolvedSession.toString(); @@ -1238,13 +1247,20 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const stopWatch = StopWatch.create(false); let firstProgress: number | undefined; const measuredProgress = (parts: IChatProgress[]) => { + // Real progress has started — cancel the pending "preparing" status. + preparingStatus.dispose(); if (firstProgress === undefined && parts.some(isFirstVisibleProgressPart)) { firstProgress = stopWatch.elapsed(); } progress(parts); }; - const completedTurn = await this._handleTurn(resolvedSession, request, measuredProgress, cancellationToken); + let completedTurn: Turn | undefined; + try { + completedTurn = await this._handleTurn(resolvedSession, request, measuredProgress, cancellationToken); + } finally { + preparingStatus.dispose(); + } const details = this._getTurnResponseDetails(request.sessionResource, resolvedSession, completedTurn); const errorDetails = this._getTurnErrorDetails(completedTurn); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/importLocalConversationToAgentSession.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/importLocalConversationToAgentSession.ts index 7e146e2d9310cd..ae0dc953c9116e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/importLocalConversationToAgentSession.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/importLocalConversationToAgentSession.ts @@ -38,6 +38,11 @@ function stringifyToolInput(rawInput: unknown): string { * text) as a markdown link so it survives migration instead of leaving a gap * where the chip used to be. Falls back to plain label text when no URI is * available. + * + * The source chip shows a short label — a file's basename or a symbol's name — + * never a workspace-relative path. Some inline references carry that path in + * their `name`, so a path-like label is collapsed to the URI's basename to + * avoid leaking the tree into the imported transcript. */ function inlineReferenceToMarkdown(reference: IChatContentInlineReference['inlineReference'], name: string | undefined): string { let uri: URI | undefined; @@ -47,18 +52,23 @@ function inlineReferenceToMarkdown(reference: IChatContentInlineReference['inlin } else { // `Location` carries the URI directly; `IWorkspaceSymbol` nests it under // `location` and supplies its own display name. - const location = reference as { uri?: URI; location?: { uri?: URI } }; + const location = reference as { uri?: URI; location?: { uri?: URI }; name?: string }; if (URI.isUri(location.uri)) { uri = location.uri; } else if (URI.isUri(location.location?.uri)) { uri = location.location.uri; - label = label ?? (reference as { name?: string }).name; + label = label ?? location.name; } } if (!uri) { return label ?? ''; } - return `[${label ?? basename(uri)}](${uri.toString()})`; + // A missing or path-like label (contains a path separator) would render the + // full workspace-relative path; the source chip only shows the basename. + if (!label || /[\\/]/.test(label)) { + label = basename(uri); + } + return `[${label}](${uri.toString()})`; } /** diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 87e17bf0c1b4a9..4d1290663dd6cb 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -1562,10 +1562,14 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N if (openOptions.replaceEditor) { // TODO: Do not rely on active editor const activeEditor = editorGroupService.activeGroup.activeEditor; - if (!activeEditor || !(activeEditor instanceof ChatEditorInput)) { - throw new Error('No active chat editor to replace'); + if (activeEditor instanceof ChatEditorInput) { + await editorService.replaceEditors([{ editor: activeEditor, replacement: { resource: sessionResource, options } }], editorGroupService.activeGroup); + } else { + // No chat editor to replace in place — fall back to opening a + // new editor so the session (and the user's pending send) is + // never lost. + await editorService.openEditor({ resource: sessionResource, options }); } - await editorService.replaceEditors([{ editor: activeEditor, replacement: { resource: sessionResource, options } }], editorGroupService.activeGroup); } else { await editorService.openEditor({ resource: sessionResource, options }); } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/importLocalConversationToAgentSession.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/importLocalConversationToAgentSession.test.ts index f886db7aa57a5e..508550148b3f78 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/importLocalConversationToAgentSession.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/importLocalConversationToAgentSession.test.ts @@ -96,6 +96,38 @@ suite('importedTurnsFromChatModel', () => { }]); }); + test('collapses a path-like inline reference label to the file basename', () => { + const uri = URI.file('/repo/src/common/appInsightsClientFactory.ts'); + const result = project(model([request('q', response([ + inlineReference(uri, 'src/common/appInsightsClientFactory.ts'), + ]))])); + + assert.deepStrictEqual(result, [{ + text: 'q', + state: TurnState.Complete, + error: undefined, + parts: [ + { kind: ResponsePartKind.Markdown, content: `[appInsightsClientFactory.ts](${uri.toString()})` }, + ], + }]); + }); + + test('keeps a short inline reference label (e.g. a symbol name) as-is', () => { + const uri = URI.file('/repo/src/common/appInsightsClientFactory.ts'); + const result = project(model([request('q', response([ + inlineReference(uri, 'logEvent'), + ]))])); + + assert.deepStrictEqual(result, [{ + text: 'q', + state: TurnState.Complete, + error: undefined, + parts: [ + { kind: ResponsePartKind.Markdown, content: `[logEvent](${uri.toString()})` }, + ], + }]); + }); + test('maps a cancelled response to a cancelled turn', () => { const result = project(model([request('q', response([markdown('partial')], { canceled: true }))])); From 4963f91f49e094b78eb449daba2651f7f75424f1 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:59:13 -0700 Subject: [PATCH 2/3] =?UTF-8?q?local=20=E2=86=92=20agent=20host=20=20migra?= =?UTF-8?q?tion=20in=E2=80=91place=20switch,=20imported=20session=20fideli?= =?UTF-8?q?ty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatSessions/chatSessions.contribution.ts | 27 +++++++++- .../widgetHosts/viewPane/chatViewPane.ts | 27 +++++++++- ...ortLocalConversationToAgentSession.test.ts | 52 +++++++++++++++++++ 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 4d1290663dd6cb..805b196da0f385 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { sep } from '../../../../../base/common/path.js'; -import { AsyncIterableProducer, raceCancellationError } from '../../../../../base/common/async.js'; +import { AsyncIterableProducer, DeferredPromise, raceCancellationError } from '../../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; @@ -22,6 +22,7 @@ import { InstantiationType, registerSingleton } from '../../../../../platform/in import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IProgressService } from '../../../../../platform/progress/common/progress.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { isDark } from '../../../../../platform/theme/common/theme.js'; import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; @@ -1526,6 +1527,7 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N const customizationHarnessService = accessor.get(ICustomizationHarnessService); const toolsService = accessor.get(ILanguageModelToolsService); const importConversationStore = accessor.get(IAgentHostImportConversationStore); + const progressService = accessor.get(IProgressService); // Determine resource to open const sessionResource = getResourceForNewChatSession(openOptions); @@ -1537,11 +1539,24 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N importConversationStore.set(sessionResource, chatSendOptions.importConversation); } - // Open chat session + // Open chat session. For a sidebar "Continue in…" migration the transition + // spans multiple async phases (load → materializing send → untitled→real + // rebind), during which the chat widget is transiently empty. Hold the + // sessions list suppressed across the whole transition so it never flashes. + let sessionsListSuppression: IDisposable | undefined; + let transitionProgress: DeferredPromise | undefined; try { switch (openOptions.position) { case ChatSessionPosition.Sidebar: { const view = await viewsService.openView(ChatViewId) as ChatViewPane; + if (chatSendOptions?.importConversation) { + sessionsListSuppression = view.beginSessionsListSuppression(); + // Show the chat view's working indicator for the whole transition (the + // widget is blank while the backend session materializes) so it does not + // look hung. Completed once the migration finishes below. + transitionProgress = new DeferredPromise(); + progressService.withProgress({ location: ChatViewId }, () => transitionProgress!.p); + } if (openOptions.type === AgentSessionProviders.Local) { await view.startNewLocalSession(); } else { @@ -1579,6 +1594,8 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N } } catch (e) { logService.error(`Failed to open '${openOptions.type}' chat session with openOptions: ${JSON.stringify(openOptions)}`, e); + sessionsListSuppression?.dispose(); + transitionProgress?.complete(); return; } @@ -1618,6 +1635,12 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N logService.error(`Failed to send initial request to '${openOptions.type}' chat session with contextOptions: ${JSON.stringify(chatSendOptions)}`, e); } } + + // The migration transition is complete (session loaded, request sent and any + // untitled→real rebind done); allow the sessions list again and stop the + // working indicator. + sessionsListSuppression?.dispose(); + transitionProgress?.complete(); } /** diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 8ca8150ec5ca98..566337a0c02968 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -11,7 +11,7 @@ import { Orientation, Sash } from '../../../../../../base/browser/ui/sash/sash.j import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Event } from '../../../../../../base/common/event.js'; -import { MutableDisposable, toDisposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { MutableDisposable, toDisposable, DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; import { autorun, IReader, observableValue } from '../../../../../../base/common/observable.js'; import { isEqual } from '../../../../../../base/common/resources.js'; @@ -107,6 +107,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private restoringSession: Promise | undefined; private readonly loadSessionCts = this._register(new MutableDisposable()); + /** While > 0 the sessions list is suppressed so a session transition's transiently-empty widget does not reveal it (see {@link beginSessionsListSuppression}). */ + private _sessionsListSuppressionCount = 0; private readonly modelRef = this._register(new MutableDisposable()); private readonly activityBadge = this._register(new MutableDisposable()); @@ -777,6 +779,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { newSessionsContainerVisible = (!!this.chatEntitlementService.sentiment.completed || this.chatEntitlementService.hasByokModels) && // chat is setup (otherwise make room for terms and welcome) (!this._widget || (this._widget.isEmpty() && !!this._widget.viewModel && !this._widget.viewModel.model.title)) && // chat widget empty (but not when model is loading or has a title) + this._sessionsListSuppressionCount === 0 && // not mid-transition (a slow session transiently shows an empty widget) !this.welcomeController?.isShowingWelcome.get(); // welcome not showing } @@ -801,6 +804,28 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { }; } + private refreshSessionsControlVisibility(): void { + const { changed } = this.updateSessionsControlVisibility(); + if (changed) { + this.relayout(); + } + } + + /** + * Suppresses the sessions list until the returned disposable is disposed. + * Used to span a whole session transition (e.g. a "Continue in…" migration: + * load → materializing send → rebind) so the transiently-empty widget never + * falls back to the list. + */ + beginSessionsListSuppression(): IDisposable { + this._sessionsListSuppressionCount++; + this.refreshSessionsControlVisibility(); + return toDisposable(() => { + this._sessionsListSuppressionCount--; + this.refreshSessionsControlVisibility(); + }); + } + getFocusedSessions(): IAgentSession[] { return this.sessionsControl?.getFocus() ?? []; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/importLocalConversationToAgentSession.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/importLocalConversationToAgentSession.test.ts index 508550148b3f78..fa17e8673927cb 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/importLocalConversationToAgentSession.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/importLocalConversationToAgentSession.test.ts @@ -27,6 +27,11 @@ suite('importedTurnsFromChatModel', () => { return { kind: 'inlineReference', inlineReference: uri, name } as IChatProgressResponseContent; } + /** Builds an inline reference from a non-URI shape (a `Location` or `IWorkspaceSymbol`). */ + function inlineRef(reference: unknown, name?: string): IChatProgressResponseContent { + return { kind: 'inlineReference', inlineReference: reference, name } as IChatProgressResponseContent; + } + function subagentTool(toolCallId: string, agentName: string, description: string, result: string): IChatProgressResponseContent { return { kind: 'toolInvocationSerialized', @@ -128,6 +133,53 @@ suite('importedTurnsFromChatModel', () => { }]); }); + test('maps a Location-shaped inline reference to its file basename', () => { + const uri = URI.file('/repo/src/common/baseTelemetrySender.ts'); + const result = project(model([request('q', response([ + inlineRef({ uri, range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 } }), + ]))])); + + assert.deepStrictEqual(result, [{ + text: 'q', + state: TurnState.Complete, + error: undefined, + parts: [ + { kind: ResponsePartKind.Markdown, content: `[baseTelemetrySender.ts](${uri.toString()})` }, + ], + }]); + }); + + test('maps a workspace-symbol inline reference using its symbol name', () => { + const uri = URI.file('/repo/src/common/baseTelemetrySender.ts'); + const result = project(model([request('q', response([ + inlineRef({ name: 'logEvent', location: { uri } }), + ]))])); + + assert.deepStrictEqual(result, [{ + text: 'q', + state: TurnState.Complete, + error: undefined, + parts: [ + { kind: ResponsePartKind.Markdown, content: `[logEvent](${uri.toString()})` }, + ], + }]); + }); + + test('falls back to the plain label when an inline reference has no resolvable URI', () => { + const result = project(model([request('q', response([ + inlineRef({ name: 'orphan' }, 'orphan'), + ]))])); + + assert.deepStrictEqual(result, [{ + text: 'q', + state: TurnState.Complete, + error: undefined, + parts: [ + { kind: ResponsePartKind.Markdown, content: 'orphan' }, + ], + }]); + }); + test('maps a cancelled response to a cancelled turn', () => { const result = project(model([request('q', response([markdown('partial')], { canceled: true }))])); From 00fc9ea6866f41ff5f03b16449fa0bf2dc0c9209 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:37:35 -0700 Subject: [PATCH 3/3] feedback updates --- .../platform/agentHost/node/agentService.ts | 6 +- .../browser/actions/chatContinueInAction.ts | 6 +- .../agentHost/agentHostSessionHandler.ts | 158 +++++++++--------- .../importLocalConversationToAgentSession.ts | 10 +- .../chatSessions/chatSessions.contribution.ts | 38 ++++- 5 files changed, 125 insertions(+), 93 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index d8aa414eb66365..02243ad22749c2 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -1214,12 +1214,14 @@ export class AgentService extends Disposable implements IAgentService { * generation never fires). Deliberately unprefixed: an imported session is a * continuation of the source chat, not a distinct kind of session, so it * should read like any other. The placeholder is later refined into a - * generated title (see the `importConversation` branch in `createSession`). + * generated title (see the `importConversation` branch in `createSession`), + * but a neutral non-empty fallback is kept so the session still reads like a + * normal chat when generation is unavailable or fails. */ private _buildImportedTitle(turns: readonly Turn[]): string { const firstText = turns.find(t => t.message?.text?.trim())?.message.text.trim(); if (!firstText) { - return ''; + return localize('agentHost.importedSessionFallback', "New Session"); } const MAX = 60; return firstText.length > MAX ? `${firstText.slice(0, MAX)}...` : firstText; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts index cfa644227ba24a..048bc9e89bd147 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts @@ -599,8 +599,10 @@ export class CreateRemoteAgentJobAction { // feels like the same chat continues rather than opening a new // tab. The source (local) session stays in chat history and is // recoverable. The sidebar path already swaps in place via - // `loadSession`, so it needs no replacement. - replaceEditor: !isSidebar, + // `loadSession`, so it needs no replacement. Pass the source + // resource (not a bare flag) so the correct editor is resolved + // at replace time even if the active editor changed meanwhile. + replaceEditorForResource: isSidebar ? undefined : sessionResource, }, { prompt: handoffPrompt, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index ac13b5d30a699e..85f51797312a14 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -1184,91 +1184,93 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC progress([{ kind: 'progressMessage', content: new MarkdownString(localize('agentHost.preparingSession', "Preparing session…")), shimmer: true }]); }, 500); - const resolvedSession = this._resolveSessionUri(request.sessionResource); - const sessionKey = resolvedSession.toString(); - - // The chat-input picker may have pre-created a provisional session - // against this resource (`IAgentHostUntitledProvisionalSessionService.getOrCreate`). - // In that case the agent already has the session + the user's chip - // selections in `state.config.values`; ensure we hold a refcounted - // subscription on it so the rest of the handler observes those. - const provisionalBackend = this._provisionalService.get(request.sessionResource); - if (provisionalBackend) { - this._ensureSessionSubscription(sessionKey); - } - - // The sessions provider may have eagerly created this session at - // folder-pick time and is holding the connection-level subscription - // open with hydrated state. Use the unmanaged accessor to peek - // without taking a fresh subscription, which would trigger a - // duplicate snapshot fetch and (in tests) unrelated mock behaviour. - const existingState = await this._readEagerlyCreatedSessionState(resolvedSession, cancellationToken); - - if (!existingState) { - // Eager-create did not produce server-side state (e.g. no - // sessions provider involved, agent host not connected at - // folder-pick time, or this session was created via a legacy/ - // test path). Fall back to the original create-then-subscribe - // flow. - // - // If a conversation was imported ("Continue in…") into this - // session, seed it as real editable history at creation time. - const imported = this._importConversationStore.take(request.sessionResource); - const model = imported?.model ?? this._createModelSelection(request.userSelectedModelId, request.modelConfiguration); - await this._createAndSubscribe(request.sessionResource, model, undefined, request.agentHostSessionConfig, imported ? { turns: imported.turns, model: imported.model } : undefined); - } else { - // Eager-created session: take a refcounted subscription so the - // handler observes state changes for the duration of the chat - // session, then wire up the per-turn machinery that - // `_createAndSubscribe` would normally set up. - this._ensureSessionSubscription(sessionKey); - this._setChatURI(request.sessionResource, this._resolveChatUriFromState(request.sessionResource, existingState)); - this._ensurePendingMessageSubscription(request.sessionResource, resolvedSession); - this._watchForServerInitiatedTurns(resolvedSession, request.sessionResource); - - // In the Agents window, the sessions provider supplies per-request - // config via `request.agentHostSessionConfig` (e.g. the user's - // permission level). Push it to the agent so its provisional record - // materializes with those values. Workbench defaults (`isolation`, - // `autoApprove`) are seeded upstream at provisional `createSession` - // time, so we don't need to merge them here. Picker selections - // already live in `existingState.config?.values` and don't need to - // be re-dispatched. - if (request.agentHostSessionConfig && Object.keys(request.agentHostSessionConfig).length > 0) { - this._dispatchAction(resolvedSession, { - type: ActionType.SessionConfigChanged, - config: request.agentHostSessionConfig, - }); + try { + const resolvedSession = this._resolveSessionUri(request.sessionResource); + const sessionKey = resolvedSession.toString(); + + // The chat-input picker may have pre-created a provisional session + // against this resource (`IAgentHostUntitledProvisionalSessionService.getOrCreate`). + // In that case the agent already has the session + the user's chip + // selections in `state.config.values`; ensure we hold a refcounted + // subscription on it so the rest of the handler observes those. + const provisionalBackend = this._provisionalService.get(request.sessionResource); + if (provisionalBackend) { + this._ensureSessionSubscription(sessionKey); } - } - // Measure turn timings so the core `interactiveSessionProviderInvoked` - // telemetry event is populated for agent-host providers. - const stopWatch = StopWatch.create(false); - let firstProgress: number | undefined; - const measuredProgress = (parts: IChatProgress[]) => { - // Real progress has started — cancel the pending "preparing" status. - preparingStatus.dispose(); - if (firstProgress === undefined && parts.some(isFirstVisibleProgressPart)) { - firstProgress = stopWatch.elapsed(); + // The sessions provider may have eagerly created this session at + // folder-pick time and is holding the connection-level subscription + // open with hydrated state. Use the unmanaged accessor to peek + // without taking a fresh subscription, which would trigger a + // duplicate snapshot fetch and (in tests) unrelated mock behaviour. + const existingState = await this._readEagerlyCreatedSessionState(resolvedSession, cancellationToken); + + if (!existingState) { + // Eager-create did not produce server-side state (e.g. no + // sessions provider involved, agent host not connected at + // folder-pick time, or this session was created via a legacy/ + // test path). Fall back to the original create-then-subscribe + // flow. + // + // If a conversation was imported ("Continue in…") into this + // session, seed it as real editable history at creation time. + const imported = this._importConversationStore.take(request.sessionResource); + const model = imported?.model ?? this._createModelSelection(request.userSelectedModelId, request.modelConfiguration); + await this._createAndSubscribe(request.sessionResource, model, undefined, request.agentHostSessionConfig, imported ? { turns: imported.turns, model: imported.model } : undefined); + } else { + // Eager-created session: take a refcounted subscription so the + // handler observes state changes for the duration of the chat + // session, then wire up the per-turn machinery that + // `_createAndSubscribe` would normally set up. + this._ensureSessionSubscription(sessionKey); + this._setChatURI(request.sessionResource, this._resolveChatUriFromState(request.sessionResource, existingState)); + this._ensurePendingMessageSubscription(request.sessionResource, resolvedSession); + this._watchForServerInitiatedTurns(resolvedSession, request.sessionResource); + + // In the Agents window, the sessions provider supplies per-request + // config via `request.agentHostSessionConfig` (e.g. the user's + // permission level). Push it to the agent so its provisional record + // materializes with those values. Workbench defaults (`isolation`, + // `autoApprove`) are seeded upstream at provisional `createSession` + // time, so we don't need to merge them here. Picker selections + // already live in `existingState.config?.values` and don't need to + // be re-dispatched. + if (request.agentHostSessionConfig && Object.keys(request.agentHostSessionConfig).length > 0) { + this._dispatchAction(resolvedSession, { + type: ActionType.SessionConfigChanged, + config: request.agentHostSessionConfig, + }); + } } - progress(parts); - }; - let completedTurn: Turn | undefined; - try { - completedTurn = await this._handleTurn(resolvedSession, request, measuredProgress, cancellationToken); + // Measure turn timings so the core `interactiveSessionProviderInvoked` + // telemetry event is populated for agent-host providers. + const stopWatch = StopWatch.create(false); + let firstProgress: number | undefined; + const measuredProgress = (parts: IChatProgress[]) => { + // Real progress has started — cancel the pending "preparing" status. + preparingStatus.dispose(); + if (firstProgress === undefined && parts.some(isFirstVisibleProgressPart)) { + firstProgress = stopWatch.elapsed(); + } + progress(parts); + }; + + const completedTurn = await this._handleTurn(resolvedSession, request, measuredProgress, cancellationToken); + const details = this._getTurnResponseDetails(request.sessionResource, resolvedSession, completedTurn); + const errorDetails = this._getTurnErrorDetails(completedTurn); + + return { + timings: { firstProgress, totalElapsed: stopWatch.elapsed() }, + ...(details ? { details } : {}), + ...(errorDetails ? { errorDetails } : {}), + }; } finally { + // Always cancel the pending "preparing" status — including when an + // await above (state read, create/subscribe, turn handling) rejects — + // so a stale status can never fire after the invocation has ended. preparingStatus.dispose(); } - const details = this._getTurnResponseDetails(request.sessionResource, resolvedSession, completedTurn); - const errorDetails = this._getTurnErrorDetails(completedTurn); - - return { - timings: { firstProgress, totalElapsed: stopWatch.elapsed() }, - ...(details ? { details } : {}), - ...(errorDetails ? { errorDetails } : {}), - }; } /** diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/importLocalConversationToAgentSession.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/importLocalConversationToAgentSession.ts index ae0dc953c9116e..0d7a4ebf499e8a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/importLocalConversationToAgentSession.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/importLocalConversationToAgentSession.ts @@ -47,6 +47,7 @@ function stringifyToolInput(rawInput: unknown): string { function inlineReferenceToMarkdown(reference: IChatContentInlineReference['inlineReference'], name: string | undefined): string { let uri: URI | undefined; let label = name; + let isSymbol = false; if (URI.isUri(reference)) { uri = reference; } else { @@ -58,14 +59,17 @@ function inlineReferenceToMarkdown(reference: IChatContentInlineReference['inlin } else if (URI.isUri(location.location?.uri)) { uri = location.location.uri; label = label ?? location.name; + isSymbol = true; } } if (!uri) { return label ?? ''; } - // A missing or path-like label (contains a path separator) would render the - // full workspace-relative path; the source chip only shows the basename. - if (!label || /[\\/]/.test(label)) { + // A file reference with a missing or path-like label (some carry the + // workspace-relative path as their name) collapses to the basename, matching + // the source chip. A symbol's name is preserved verbatim — it may legitimately + // contain a separator (e.g. C++ `operator/`) and must not be treated as a path. + if (!label || (!isSymbol && /[\\/]/.test(label))) { label = basename(uri); } return `[${label}](${uri.toString()})`; diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 805b196da0f385..beac65d1217606 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -1464,7 +1464,12 @@ function registerNewSessionInPlaceAction(type: string, displayName: string): IDi throw new BugIndicatingError(`Invalid chat session position argument: ${chatSessionPosition}`); } - await openChatSession(accessor, { type: type, displayName: localize('chat', "Chat"), position: chatSessionPosition, replaceEditor: true }); + // Resolve the editor to replace up front from the currently active + // chat editor, so the replacement targets that specific tab rather + // than whatever becomes active during the async open. + const activeEditor = accessor.get(IEditorGroupsService).activeGroup.activeEditor; + const replaceEditorForResource = activeEditor instanceof ChatEditorInput ? activeEditor.sessionResource : undefined; + await openChatSession(accessor, { type: type, displayName: localize('chat', "Chat"), position: chatSessionPosition, replaceEditorForResource }); } }); } @@ -1514,7 +1519,13 @@ export type NewChatSessionOpenOptions = { readonly type: string; readonly position: ChatSessionPosition; readonly displayName: string; - readonly replaceEditor?: boolean; + /** + * When set, the editor showing this (source) session resource is replaced + * in place with the newly opened session. The source resource is resolved + * to its concrete editor at replace time, so the correct tab is replaced + * even if the user activated a different editor during the async setup. + */ + readonly replaceEditorForResource?: URI; }; export async function openChatSession(accessor: ServicesAccessor, openOptions: NewChatSessionOpenOptions, chatSendOptions?: NewChatSessionSendOptions): Promise { @@ -1574,12 +1585,23 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N fallback: localize('chatEditorContributionName', "{0}", openOptions.displayName), } }; - if (openOptions.replaceEditor) { - // TODO: Do not rely on active editor - const activeEditor = editorGroupService.activeGroup.activeEditor; - if (activeEditor instanceof ChatEditorInput) { - await editorService.replaceEditors([{ editor: activeEditor, replacement: { resource: sessionResource, options } }], editorGroupService.activeGroup); - } else { + if (openOptions.replaceEditorForResource) { + // Replace the specific source chat editor, identified by its + // session resource — not whatever happens to be active now. The + // repository extraction and other awaits above may have run while + // the user activated a different chat editor, so consulting the + // active editor could replace an unrelated tab. + const sourceResource = openOptions.replaceEditorForResource; + let replaced = false; + for (const group of editorGroupService.groups) { + const editor = group.editors.find(e => e instanceof ChatEditorInput && resources.isEqual(e.sessionResource, sourceResource)); + if (editor) { + await editorService.replaceEditors([{ editor, replacement: { resource: sessionResource, options } }], group); + replaced = true; + break; + } + } + if (!replaced) { // No chat editor to replace in place — fall back to opening a // new editor so the session (and the user's pending send) is // never lost.