diff --git a/src/vs/platform/agentHost/common/remoteAgentHostService.ts b/src/vs/platform/agentHost/common/remoteAgentHostService.ts index 5214fe52cf6..73cf6beb37c 100644 --- a/src/vs/platform/agentHost/common/remoteAgentHostService.ts +++ b/src/vs/platform/agentHost/common/remoteAgentHostService.ts @@ -22,6 +22,12 @@ export const RemoteAgentHostsSettingId = 'chat.remoteAgentHosts'; /** Configuration key to enable remote agent host connections. */ export const RemoteAgentHostsEnabledSettingId = 'chat.remoteAgentHostsEnabled'; +/** + * Configuration key that controls whether online dev tunnels and + * configured SSH remote agent hosts are auto-connected at startup. + */ +export const RemoteAgentHostAutoConnectSettingId = 'chat.remoteAgentHostsAutoConnect'; + export const enum RemoteAgentHostEntryType { WebSocket = 'websocket', SSH = 'ssh', diff --git a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts index 77b2797d5c8..a8bbc82367d 100644 --- a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -14,8 +14,8 @@ import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { AgentSession, IAgentConnection, IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; -import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js'; import { IResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; +import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js'; import type { IFileEdit, IModelSelection, ISessionConfigPropertySchema, ISessionState, ISessionSummary } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import { StateComponents } from '../../../../platform/agentHost/common/state/sessionState.js'; @@ -24,12 +24,12 @@ import { IChatSendRequestOptions, IChatService } from '../../../../workbench/con import { IChatSessionFileChange, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; -import { agentHostSessionWorkspaceKey } from '../../../common/agentHostSessionWorkspace.js'; -import { diffsToChanges, diffsEqual, mapProtocolStatus } from '../../../common/agentHostDiffs.js'; +import { diffsEqual, diffsToChanges, mapProtocolStatus } from '../../../common/agentHostDiffs.js'; import { buildMutableConfigSchema, IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../common/agentHostSessionsProvider.js'; +import { agentHostSessionWorkspaceKey } from '../../../common/agentHostSessionWorkspace.js'; import { isSessionConfigComplete } from '../../../common/sessionConfig.js'; -import { ISendRequestOptions, ISessionChangeEvent } from '../../../services/sessions/common/sessionsProvider.js'; import { IChat, IGitHubInfo, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus } from '../../../services/sessions/common/session.js'; +import { ISendRequestOptions, ISessionChangeEvent } from '../../../services/sessions/common/sessionsProvider.js'; // ============================================================================ // AgentHostSessionAdapter — shared adapter for local and remote sessions diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index 5d05396bfb1..ba888405c08 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -197,11 +197,10 @@ export class WorkspacePicker extends Disposable { return; } if (item.remoteProvider && item.browseActionIndex === undefined) { - if (item.remoteProvider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX)) { - // Disconnected tunnel — trigger connection flow - this.commandService.executeCommand('workbench.action.sessions.connectViaTunnel'); - } else { - // Disconnected SSH host — show options menu after widget hides + if (!item.remoteProvider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX)) { + // Disconnected SSH host — show options menu after widget hides. + // (Disconnected tunnels are rendered as disabled with a + // refresh toolbar action, so onSelect doesn't fire for them.) this._showRemoteHostOptionsDelayed(item.remoteProvider); } } else if (item.browseActionIndex !== undefined) { @@ -437,11 +436,27 @@ export class WorkspacePicker extends Disposable { const status = provider.connectionStatus!.get(); const isConnected = status === RemoteAgentHostConnectionStatus.Connected; const providerBrowseIndex = allBrowseActions.findIndex(a => a.providerId === provider.id); + const isTunnel = provider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX); const toolbarActions: IAction[] = []; - // Gear menu only for SSH hosts, not tunnel providers - if (!provider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX)) { + if (isTunnel) { + // Offline/connecting tunnels: surface a refresh button that + // attempts to (re)connect in case the cached status is stale. + if (!isConnected && providerBrowseIndex >= 0) { + const browseIndex = providerBrowseIndex; + toolbarActions.push(toAction({ + id: `workspacePicker.remote.refresh.${provider.id}`, + label: localize('workspacePicker.refreshTunnel', "Attempt to Connect"), + class: ThemeIcon.asClassName(Codicon.refresh), + run: () => { + this.actionWidgetService.hide(); + this._executeBrowseAction(browseIndex); + }, + })); + } + } else { + // Gear menu only for SSH hosts, not tunnel providers toolbarActions.push(toAction({ id: `workspacePicker.remote.gear.${provider.id}`, label: localize('workspacePicker.remoteOptions', "Options"), @@ -453,15 +468,13 @@ export class WorkspacePicker extends Disposable { })); } - const isTunnel = provider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX); - items.push({ kind: ActionListItemKind.Action, label: provider.label, description: this._getStatusDescription(status), hover: { content: this._getStatusHover(status, provider.remoteAddress) }, group: { title: '', icon: isTunnel ? Codicon.cloud : Codicon.remote }, - disabled: isTunnel ? false : !isConnected, + disabled: !isConnected, item: { browseActionIndex: isConnected && providerBrowseIndex >= 0 ? providerBrowseIndex : undefined, remoteProvider: provider, diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 2349a0a4699..0f12071dbdc 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -10,7 +10,7 @@ import { URI } from '../../../../base/common/uri.js'; import * as nls from '../../../../nls.js'; import { agentHostAuthority } from '../../../../platform/agentHost/common/agentHostUri.js'; import { type AgentProvider, type IAgentConnection } from '../../../../platform/agentHost/common/agentService.js'; -import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostEntry, IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, getEntryAddress } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostEntry, IRemoteAgentHostService, RemoteAgentHostAutoConnectSettingId, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, getEntryAddress } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { TunnelAgentHostsSettingId } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; import { type IProtectedResourceMetadata, type URI as ProtocolURI } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { type IAgentInfo, type ICustomizationRef, type IRootState } from '../../../../platform/agentHost/common/state/sessionState.js'; @@ -102,7 +102,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc // Reconcile providers when configured entries change this._register(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(RemoteAgentHostsSettingId) || e.affectsConfiguration(RemoteAgentHostsEnabledSettingId)) { + if (e.affectsConfiguration(RemoteAgentHostsSettingId) || e.affectsConfiguration(RemoteAgentHostsEnabledSettingId) || e.affectsConfiguration(RemoteAgentHostAutoConnectSettingId)) { this._reconcile(); } })); @@ -194,6 +194,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc * sshConfigHost but no active connection. */ private _reconnectSSHEntries(): void { + const autoConnect = this._configurationService.getValue(RemoteAgentHostAutoConnectSettingId); const entries = this._remoteAgentHostService.configuredEntries; for (const entry of entries) { if (entry.connection.type !== RemoteAgentHostEntryType.SSH || !entry.connection.sshConfigHost) { @@ -208,6 +209,9 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc if (hasConnection || this._pendingSSHReconnects.has(sshConfigHost)) { continue; } + if (!autoConnect) { + continue; + } this._pendingSSHReconnects.add(sshConfigHost); this._logService.info(`[RemoteAgentHost] Re-establishing SSH tunnel for ${sshConfigHost}`); this._sshService.reconnect(sshConfigHost, entry.name).then(() => { @@ -216,6 +220,10 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc }).catch(err => { this._pendingSSHReconnects.delete(sshConfigHost); this._logService.error(`[RemoteAgentHost] SSH reconnect failed for ${sshConfigHost}`, err); + // Host is unreachable — unpublish any cached sessions we + // were showing so the UI doesn't list stale entries for a + // host we cannot currently reach. + this._providerInstances.get(address)?.unpublishCachedSessions(); }); } } @@ -590,6 +598,13 @@ Registry.as(ConfigurationExtensions.Configuration).regis scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], }, + [RemoteAgentHostAutoConnectSettingId]: { + type: 'boolean', + description: nls.localize('chat.remoteAgentHosts.autoConnect', "Automatically connect to online dev tunnel and SSH-configured remote agent hosts on startup. When disabled, cached sessions are still shown but connections are established only on demand."), + default: true, + scope: ConfigurationScope.APPLICATION, + tags: ['experimental', 'advanced'], + }, 'chat.sshRemoteAgentHostCommand': { type: 'string', description: nls.localize('chat.sshRemoteAgentHostCommand', "For development: Override the command used to start the remote agent host over SSH. When set, skips automatic CLI installation and runs this command instead. The command must print a WebSocket URL matching ws://127.0.0.1:PORT (optionally with ?tkn=TOKEN) to stdout or stderr./"), diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 17786c2c4c9..c4c81f5a9c7 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -19,6 +19,7 @@ import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from import { RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; @@ -53,6 +54,62 @@ function wellKnownAgentProvider(sessionType: string): string | undefined { return undefined; } +/** Storage key prefix for cached session summaries, per remote address. */ +const CACHED_SESSIONS_STORAGE_PREFIX = 'remoteAgentHost.cachedSessions.'; + +/** Maximum number of cached session summaries persisted per host. */ +const CACHED_SESSIONS_MAX_PER_HOST = 100; + +/** + * Serialized shape of an {@link IAgentSessionMetadata} suitable for + * persisting via {@link IStorageService}. URIs are stored as strings + * and diffs are intentionally omitted (they are re-populated when the + * connection refreshes sessions). + */ +interface ISerializedSessionMetadata { + readonly session: string; + readonly startTime: number; + readonly modifiedTime: number; + readonly summary?: string; + readonly model?: IAgentSessionMetadata['model']; + readonly workingDirectory?: string; + readonly isRead?: boolean; + readonly isDone?: boolean; + readonly project?: { readonly uri: string; readonly displayName: string }; +} + +function serializeMetadata(meta: IAgentSessionMetadata): ISerializedSessionMetadata { + return { + session: meta.session.toString(), + startTime: meta.startTime, + modifiedTime: meta.modifiedTime, + summary: meta.summary, + model: meta.model, + workingDirectory: meta.workingDirectory?.toString(), + isRead: meta.isRead, + isDone: meta.isDone, + project: meta.project ? { uri: meta.project.uri.toString(), displayName: meta.project.displayName } : undefined, + }; +} + +function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMetadata | undefined { + try { + return { + session: URI.parse(raw.session), + startTime: raw.startTime, + modifiedTime: raw.modifiedTime, + summary: raw.summary, + model: raw.model, + workingDirectory: raw.workingDirectory ? URI.parse(raw.workingDirectory) : undefined, + isRead: raw.isRead, + isDone: raw.isDone, + project: raw.project ? { uri: URI.parse(raw.project.uri), displayName: raw.project.displayName } : undefined, + }; + } catch { + return undefined; + } +} + function toLocalProjectUri(uri: URI, connectionAuthority: string): URI { return uri.scheme === Schemas.file ? toAgentHostUri(uri, connectionAuthority) : uri; } @@ -120,11 +177,36 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid private readonly _connectionListeners = this._register(new DisposableStore()); private readonly _connectionAuthority: string; private readonly _connectOnDemand: (() => Promise) | undefined; + /** Storage key used for persisting {@link _sessionCache} snapshots. */ + private readonly _storageKey: string; + /** + * Set when {@link _sessionCache} has changed since the last persist. + * The actual write happens on the next `onWillSaveState` signal from + * {@link IStorageService} so that bursts of notifications do not + * repeatedly re-serialize the whole cache. + */ + private _cacheDirty = false; + /** + * Snapshot of the source metadata for each adapter in {@link _sessionCache}, + * keyed by raw session ID. Captured in {@link createAdapter} and re-used by + * {@link _persistCache} to serialize sessions without having to reconstruct + * every `IAgentSessionMetadata` field from observables. + */ + private readonly _metaByRawId = new Map(); + /** + * When `true`, the provider has been marked unreachable and sessions are + * hidden from {@link getSessions}, even though {@link _sessionCache} and + * persistent storage are retained. Cleared when a new connection is wired + * up in {@link setConnection}, at which point the cached entries are + * re-announced so the UI can repopulate. + */ + private _unpublished = false; constructor( config: IRemoteAgentHostSessionsProviderConfig, @IFileDialogService private readonly _fileDialogService: IFileDialogService, @INotificationService private readonly _notificationService: INotificationService, + @IStorageService private readonly _storageService: IStorageService, @IChatSessionsService chatSessionsService: IChatSessionsService, @IChatService chatService: IChatService, @IChatWidgetService chatWidgetService: IChatWidgetService, @@ -139,6 +221,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this.id = `agenthost-${this._connectionAuthority}`; this.label = displayName; this.remoteAddress = config.address; + this._storageKey = `${CACHED_SESSIONS_STORAGE_PREFIX}${this._connectionAuthority}`; this.browseActions = [{ label: localize('folders', "Folders"), @@ -146,6 +229,30 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid providerId: this.id, run: () => this._browseForFolder(), }]; + + this._loadCachedSessions(); + + this._register(this._onDidChangeSessions.event(e => { + if (this._unpublished) { + return; + } + if (e.added.length > 0 || e.removed.length > 0 || e.changed.length > 0) { + this._cacheDirty = true; + } + for (const removed of e.removed) { + const rawId = this._rawIdFromChatId(removed.sessionId); + if (rawId) { + this._metaByRawId.delete(rawId); + } + } + })); + + this._register(this._storageService.onWillSaveState(() => { + if (this._cacheDirty) { + this._persistCache(); + this._cacheDirty = false; + } + })); } // -- BaseAgentHostSessionsProvider hooks --------------------------------- @@ -158,6 +265,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid const provider = AgentSession.provider(meta.session) ?? DEFAULT_AGENT_HOST_PROVIDER; const resourceScheme = remoteAgentHostSessionTypeId(this._connectionAuthority, provider); const logicalType = this._logicalSessionTypeForProvider(provider); + this._metaByRawId.set(AgentSession.id(meta.session), meta); return new AgentHostSessionAdapter(meta, this.id, resourceScheme, logicalType, { icon: this.icon, description: new MarkdownString().appendText(this.label), @@ -175,6 +283,10 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid return wellKnownAgentProvider(sessionType) ?? sessionType.substring(`remote-${this._connectionAuthority}-`.length); } + override getSessions(): ISession[] { + return this._unpublished ? [] : super.getSessions(); + } + protected override mapWorkingDirectoryUri(uri: URI): URI { return toAgentHostUri(uri, this._connectionAuthority); } @@ -237,6 +349,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this._sessionStateSubscriptions.clearAndDisposeAll(); this._connection = connection; this._defaultDirectory = defaultDirectory; + this._unpublished = false; // Dynamically discover session types from the host's advertised agents. const rootStateValue = connection.rootState.value; @@ -256,7 +369,10 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid /** * Clear the connection, e.g. when the remote host disconnects. - * Retains the provider registration so it remains visible in the UI. + * Retains the provider registration so it remains visible in the UI, + * and **preserves** the cached session list so previously loaded + * sessions stay visible while we're offline. Callers that know the + * host is unreachable should follow up with {@link unpublishCachedSessions}. */ clearConnection(): void { this._connectionListeners.clear(); @@ -279,19 +395,91 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this._onDidChangeSessionTypes.fire(); } - const removed: ISession[] = Array.from(this._sessionCache.values()); + // Drop only the transient pending/draft session; keep the persisted + // cache so the workspace picker keeps showing offline sessions. if (this._pendingSession) { - removed.push(this._pendingSession); + const pending = this._pendingSession; this._pendingSession = undefined; + this._onDidChangeSessions.fire({ added: [], removed: [pending], changed: [] }); } - this._sessionCache.clear(); - this._runningSessionConfigs.clear(); + + // Reset the in-memory cache-initialized flag so a fresh connection + // triggers a full list refresh (which will reconcile against the + // persisted entries we keep on disk). this._cacheInitialized = false; + } + + /** + * Hide cached sessions from the UI without discarding them. Called by the + * host-tracking contributions when they determine the remote host is + * unreachable (tunnel offline or SSH reconnect failed). The in-memory + * cache and persisted storage are left intact so the sessions can be + * restored if the host comes back online in this session, or on the next + * launch. The next {@link setConnection} call re-announces the cached + * entries. + */ + unpublishCachedSessions(): void { + if (this._unpublished) { + return; + } + this._unpublished = true; + const removed: ISession[] = Array.from(this._sessionCache.values()); if (removed.length > 0) { this._onDidChangeSessions.fire({ added: [], removed, changed: [] }); } } + /** Load persisted session summaries into {@link _sessionCache}. */ + private _loadCachedSessions(): void { + const parsed = this._storageService.getObject(this._storageKey, StorageScope.APPLICATION); + if (!Array.isArray(parsed)) { + return; + } + for (const entry of parsed as readonly ISerializedSessionMetadata[]) { + const meta = deserializeMetadata(entry); + if (!meta) { + continue; + } + const rawId = AgentSession.id(meta.session); + if (this._sessionCache.has(rawId)) { + continue; + } + const cached = this.createAdapter(meta); + this._sessionCache.set(rawId, cached); + } + } + + /** + * Persist the current {@link _sessionCache} to storage, capping at + * {@link CACHED_SESSIONS_MAX_PER_HOST} most-recently-modified entries. + * Mutable fields are read from each adapter's observables and overlaid on + * top of the original metadata snapshot captured in {@link _metaByRawId}. + */ + private _persistCache(): void { + const entries: ISerializedSessionMetadata[] = []; + for (const [rawId, adapter] of this._sessionCache) { + const base = this._metaByRawId.get(rawId); + if (!base) { + continue; + } + entries.push(serializeMetadata({ + ...base, + summary: adapter.title.get() || base.summary, + modifiedTime: adapter.updatedAt.get().getTime(), + model: adapter.modelSelection ?? base.model, + isRead: adapter.isRead.get(), + isDone: adapter.isArchived.get(), + })); + } + if (entries.length === 0) { + this._storageService.remove(this._storageKey, StorageScope.APPLICATION); + return; + } + entries.sort((a, b) => b.modifiedTime - a.modifiedTime); + const limited = entries.slice(0, CACHED_SESSIONS_MAX_PER_HOST); + this._storageService.store(this._storageKey, JSON.stringify(limited), StorageScope.APPLICATION, StorageTarget.USER); + } + // -- Session-type sync --------------------------------------------------- /** @@ -365,7 +553,12 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid private async _browseForFolder(): Promise { // Establish connection on demand if a hook is provided (e.g. tunnel relay) if (!this._connection && this._connectOnDemand) { - await this._connectOnDemand(); + try { + await this._connectOnDemand(); + } catch (err) { + this._notificationService.error(localize('connectFailed', "Failed to connect to remote agent host '{0}': {1}", this.label, err instanceof Error ? err.message : String(err))); + return undefined; + } } if (!this._connection) { diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts index 4f8e3fd342f..2bd9cf56525 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts @@ -7,7 +7,7 @@ import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../ import { isWeb } from '../../../../base/common/platform.js'; import { mainWindow } from '../../../../base/browser/window.js'; import * as nls from '../../../../nls.js'; -import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IRemoteAgentHostService, RemoteAgentHostAutoConnectSettingId, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { ITunnelAgentHostService, TUNNEL_ADDRESS_PREFIX, type ITunnelInfo } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -42,6 +42,12 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc private readonly _providerInstances = new Map(); private readonly _pendingConnects = new Map>(); private _lastStatusCheck = 0; + /** + * `false` until the first {@link _silentStatusCheck} resolves. Until then + * we keep newly-created providers in the `Connecting` state so the picker + * doesn't briefly show every cached tunnel as "Offline" on startup. + */ + private _initialStatusChecked = false; /** Previous connection status per address — used to detect Connected→Disconnected transitions. */ private readonly _previousStatuses = new Map(); @@ -171,9 +177,13 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc RemoteAgentHostSessionsProvider, { address, name, - connectOnDemand: () => this._connectTunnel(address), + connectOnDemand: () => this._connectTunnel(address, { userInitiated: true }), }, ); + // Surface as "Connecting" until the first silent status check or an + // auto-connect attempt determines the real state; otherwise the picker + // flashes "Offline" for every cached tunnel on startup. + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connecting); store.add(provider); store.add(this._sessionsProvidersService.registerProvider(provider)); this._providerInstances.set(address, provider); @@ -190,6 +200,10 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc provider.setConnectionStatus(connectionInfo.status); } else if (this._pendingConnects.has(address)) { provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connecting); + } else if (!this._initialStatusChecked) { + // Keep the initial "Connecting" state so the picker doesn't + // flash "Offline" before the first silent status check runs. + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connecting); } else { provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Disconnected); } @@ -219,7 +233,7 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc * Establish a relay connection to a cached tunnel. Called on demand * when the user invokes the browse action on an online-but-not-connected tunnel. */ - private _connectTunnel(address: string): Promise { + private _connectTunnel(address: string, options: { readonly userInitiated: boolean }): Promise { const existing = this._pendingConnects.get(address); if (existing) { return existing; @@ -239,15 +253,16 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc const promise = (async () => { // Show a progress notification after a short delay so quick - // connects don't flash a notification. + // connects don't flash a notification. Only show for user-initiated + // connects; background auto-connects and reconnects stay silent. let handle: { close(): void } | undefined; - const timer = setTimeout(() => { + const timer = options.userInitiated ? setTimeout(() => { handle = this._notificationService.notify({ severity: Severity.Info, message: nls.localize('tunnelConnecting', "Connecting to tunnel '{0}'...", cached.name), progress: { infinite: true }, }); - }, 1000); + }, 1000) : undefined; this._updateConnectionStatuses(); try { @@ -279,7 +294,9 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc } throw err; } finally { - clearTimeout(timer); + if (timer !== undefined) { + clearTimeout(timer); + } handle?.close(); this._pendingConnects.delete(address); this._updateConnectionStatuses(); @@ -412,7 +429,7 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc } this._reconnectAttempts.set(address, attempt + 1); - this._connectTunnel(address).catch(() => { /* _connectTunnel already re-schedules on failure */ }); + this._connectTunnel(address, { userInitiated: false }).catch(() => { /* _connectTunnel already re-schedules on failure */ }); }, delay); this._reconnectTimeouts.set(address, timer); } @@ -607,6 +624,8 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc private async _silentStatusCheck(): Promise { const enabled = this._configurationService.getValue(RemoteAgentHostsEnabledSettingId); if (!enabled) { + this._initialStatusChecked = true; + this._updateConnectionStatuses(); return; } @@ -618,6 +637,8 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc onlineTunnels = await this._tunnelService.listTunnels({ silent: true }); } catch { // No cached token or network error — leave statuses as-is + this._initialStatusChecked = true; + this._updateConnectionStatuses(); return; } @@ -643,7 +664,8 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc // Update online/offline status based on hostConnectionCount. // For tunnels, Connected means "host is online" (clickable to connect), // Disconnected means "host is offline". Actual relay connection - // establishment happens when the user clicks the tunnel. + // establishment happens when the user clicks the tunnel (or via + // auto-connect below when enabled). const onlineTunnelMap = new Map(onlineTunnels.map(t => [t.tunnelId, t])); for (const [address, provider] of this._providerInstances) { // Skip tunnels that already have an active relay connection @@ -660,13 +682,18 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connected); } else { provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Disconnected); + // Host is not online — drop any cached sessions we were + // showing for it so the UI doesn't list stale entries. + provider.unpublishCachedSessions(); } } - // Auto-connect online tunnels that aren't connected yet. - // On web there is no workspace picker to trigger manual connection, - // so we connect eagerly when a tunnel is discovered and online. - if (isWeb) { + // Auto-connect online tunnels that aren't connected yet when the + // user has opted into auto-connect (default on). This mirrors the + // web embedder behaviour where no workspace picker is available + // to trigger manual connection. + const autoConnect = this._configurationService.getValue(RemoteAgentHostAutoConnectSettingId); + if (autoConnect) { for (const tunnel of onlineTunnels) { if (tunnel.hostConnectionCount > 0) { const address = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`; @@ -674,12 +701,15 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc c => c.address === address && c.status === RemoteAgentHostConnectionStatus.Connected ); if (!alreadyConnected) { - this._connectTunnel(address); + this._connectTunnel(address, { userInitiated: false }); } } } } } + + this._initialStatusChecked = true; + this._updateConnectionStatuses(); } } diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 5109b3ebad7..04d2ee6d5d6 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -177,7 +177,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; }; } -function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean }): RemoteAgentHostSessionsProvider { +function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; storageService?: IStorageService; noConnection?: boolean }): RemoteAgentHostSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IFileDialogService, {}); @@ -196,7 +196,7 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne instantiationService.stub(ILanguageModelsService, { lookupLanguageModel: () => undefined, }); - instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); + instantiationService.stub(IStorageService, overrides?.storageService ?? disposables.add(new InMemoryStorageService())); const config: IRemoteAgentHostSessionsProviderConfig = { address: overrides?.address ?? 'localhost:4321', @@ -204,7 +204,9 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne }; const provider = disposables.add(instantiationService.createInstance(RemoteAgentHostSessionsProvider, config)); - provider.setConnection(connection); + if (!overrides?.noConnection) { + provider.setConnection(connection); + } return provider; } @@ -761,6 +763,61 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.strictEqual(session!.loading.get(), false); })); + test('unpublishCachedSessions hides sessions but retains persisted cache', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + connection.addSession(createSession('keep-me', { summary: 'Keep Me' })); + const provider = createProvider(disposables, connection, { storageService }); + await timeout(0); + assert.strictEqual(provider.getSessions().length, 1); + + const events: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => events.push(e))); + + provider.unpublishCachedSessions(); + + // Sessions are hidden from the listing immediately. + assert.deepStrictEqual( + { + sessionCount: provider.getSessions().length, + eventRemovedTitles: events.flatMap(e => e.removed.map(s => s.title.get())), + }, + { sessionCount: 0, eventRemovedTitles: ['Keep Me'] }, + ); + + // Flush triggers onWillSaveState; the metadata must survive so the + // session re-serializes instead of being dropped from storage. + await storageService.flush(); + + const provider2 = createProvider(disposables, new MockAgentConnection(), { storageService, noConnection: true }); + assert.deepStrictEqual( + provider2.getSessions().map(s => s.title.get()), + ['Keep Me'], + ); + })); + + test('setConnection after unpublishCachedSessions restores cached sessions', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + connection.addSession(createSession('restore-me', { summary: 'Restore Me' })); + const provider = createProvider(disposables, connection); + await timeout(0); + assert.strictEqual(provider.getSessions().length, 1); + + provider.unpublishCachedSessions(); + assert.strictEqual(provider.getSessions().length, 0); + + // Simulate the host coming back online with a fresh connection that + // still reports the same session. + const reconnected = new MockAgentConnection(); + disposables.add(toDisposable(() => reconnected.dispose())); + reconnected.addSession(createSession('restore-me', { summary: 'Restore Me' })); + provider.setConnection(reconnected); + await timeout(0); + + assert.deepStrictEqual( + provider.getSessions().map(s => s.title.get()), + ['Restore Me'], + ); + })); + test('sendAndCreateChat throws for unknown session', async () => { const provider = createProvider(disposables, connection); await assert.rejects(