Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 20 additions & 9 deletions src/vs/sessions/browser/parts/chatGroupView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,30 +230,41 @@ export class ChatGroupView extends Disposable implements ISerializableView {
if (archived) {
const action = getChatSessionArchiveActionPresentation(this._archiveActionWording.read(reader)).unarchive;
return {
message: localize('sessionReadOnlyBanner.archived', "Archived sessions are read-only."),
action: {
label: action.title.value,
run: () => this._commandService.executeCommand(UNARCHIVE_SESSION_COMMAND_ID, context.session),
archived: true,
content: {
message: localize('sessionReadOnlyBanner.archived', "Archived sessions are read-only."),
action: {
label: action.title.value,
run: () => this._commandService.executeCommand(UNARCHIVE_SESSION_COMMAND_ID, context.session),
},
},
};
}
return { message: localize('sessionReadOnlyBanner.message', "This chat is read-only") };
return { archived: false, content: { message: localize('sessionReadOnlyBanner.message', "This chat is read-only") } };
});

const surface = derived<IChatGroupSurface>(reader => {
const readOnly = readOnlyContent.read(reader);
if (readOnly) {
return { banner: readOnly, recovery: undefined };
if (readOnly?.archived) {
return { banner: readOnly.content, recovery: undefined };
}

// Keep the banner while history loads to avoid flashing the centered recovery state.
const view = currentView.read(reader);
const recovery = view?.hasVisibleTranscriptContent.read(reader)
const transcriptSettled = view === undefined || !view.isLoadingTranscript.read(reader);
const recovery = !transcriptSettled || view?.hasVisibleTranscriptContent.read(reader)
? undefined
: this._connection.recoveryContent.read(reader);
if (recovery) {
return { banner: undefined, recovery };
}
return { banner: this._connection.bannerContent.read(reader), recovery: undefined };
// Explain connection-related read-only state before falling back to the generic notice.
const connectionBanner = this._connection.bannerContent.read(reader);
if (connectionBanner) {
return { banner: connectionBanner, recovery: undefined };
}

return { banner: readOnly?.content, recovery: undefined };
});

this._contextDisposables.add(autorun(reader => {
Expand Down
8 changes: 8 additions & 0 deletions src/vs/sessions/browser/parts/chatView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ export abstract class AbstractChatView extends Disposable implements ISerializab
*/
readonly hasVisibleTranscriptContent: IObservable<boolean> = constObservable(false);

/**
* Whether this view is still resolving its chat model, during which
* {@link hasVisibleTranscriptContent} is not yet meaningful — it reads `false` for a transcript
* that simply has not arrived yet as well as for one that does not exist. Views that never load
* a model report `false`, since for them the answer is already final.
*/
readonly isLoadingTranscript: IObservable<boolean> = constObservable(false);

/**
* Show the given chat in this view. The default implementation is a
* no-op; subclasses that host a chat widget (e.g. `ChatView`) override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
}

.remote-host-unavailable-empty-state.hidden,
.remote-host-unavailable-empty-state-description.hidden,
.remote-host-unavailable-empty-state-progress.hidden,
.remote-host-unavailable-empty-state-action.hidden,
.remote-host-unavailable-empty-state-auto-connect.hidden {
Expand Down
10 changes: 7 additions & 3 deletions src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import { defaultButtonStyles, defaultCheckboxStyles } from '../../../platform/th

export interface IRemoteHostUnavailableEmptyStateContent {
readonly title: string;
readonly description: string;
/** Omitted when the title already says everything, leaving the title and action to speak. */
readonly description?: string;
readonly progress?: string;
readonly action?: {
readonly label: string;
Expand Down Expand Up @@ -59,7 +60,7 @@ export class RemoteHostUnavailableEmptyState extends Disposable {
icon.appendChild(renderIcon(Codicon.debugDisconnect));

this._title = dom.append(this.domNode, dom.$('h2.remote-host-unavailable-empty-state-title'));
this._description = dom.append(this.domNode, dom.$('p.remote-host-unavailable-empty-state-description'));
this._description = dom.append(this.domNode, dom.$('p.remote-host-unavailable-empty-state-description.hidden'));
this._progress = dom.append(this.domNode, dom.$('p.remote-host-unavailable-empty-state-progress.hidden'));
// Connect progress changes while the user waits (waiting → download
// percentage), so announce it politely rather than leaving it silent.
Expand All @@ -85,6 +86,8 @@ export class RemoteHostUnavailableEmptyState extends Disposable {
this._autoConnectListener.clear();
this.domNode.classList.toggle('hidden', !content);
if (!content) {
this._description.textContent = '';
this._description.classList.add('hidden');
this._progress.textContent = '';
this._progress.classList.add('hidden');
this._actionContainer.classList.add('hidden');
Expand All @@ -98,7 +101,8 @@ export class RemoteHostUnavailableEmptyState extends Disposable {

this.domNode.setAttribute('aria-label', content.title);
this._title.textContent = content.title;
this._description.textContent = content.description;
this._description.textContent = content.description ?? '';
this._description.classList.toggle('hidden', !content.description);
this._progress.textContent = content.progress ?? '';
this._progress.classList.toggle('hidden', !content.progress);
if (!content.action) {
Expand Down
96 changes: 69 additions & 27 deletions src/vs/sessions/browser/parts/sessionRemoteConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import { Disposable, MutableDisposable } from '../../../base/common/lifecycle.js
import { autorun, derived, derivedObservableWithCache, IObservable, IReader, observableSignal, observableValue, transaction } from '../../../base/common/observable.js';
import { localize } from '../../../nls.js';
import { ILogService } from '../../../platform/log/common/log.js';
import { isAgentHostProvider } from '../../common/agentHostSessionsProvider.js';
import { IAgentHostConnectionLabels, isAgentHostProvider } from '../../common/agentHostSessionsProvider.js';
import { SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus } from '../../services/sessions/common/session.js';
import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js';
import { ISessionsProvider } from '../../services/sessions/common/sessionsProvider.js';
import { ISessionsProvidersService } from '../../services/sessions/browser/sessionsProvidersService.js';
import { IRemoteHostUnavailableEmptyStateContent } from './remoteHostUnavailableEmptyState.js';
import { ISessionReadOnlyBannerContent } from './sessionReadOnlyBanner.js';
Expand Down Expand Up @@ -63,6 +64,13 @@ export class SessionRemoteConnection extends Disposable {
* `connect()` joins an in-flight dial rather than starting a second one.
*/
private readonly _autoConnected = observableValue<IActiveSession | undefined>(this, undefined);
/**
* The session a connect has already been started for, however it ended. Distinguishes a host
* that has never been dialled — where the action reads as "Connect" — from one that was tried
* and did not come up, where it reads as "Retry". Latched separately from {@link _attempt},
* which clears once an attempt settles and so cannot answer "has this been tried at all".
*/
private readonly _connectAttempted = observableValue<IActiveSession | undefined>(this, undefined);
/**
* Whether the host should be started without waiting for a click. Gated on a
* stopped host the provider can start, and on not having tried yet — one
Expand Down Expand Up @@ -170,6 +178,7 @@ export class SessionRemoteConnection extends Disposable {
this._session.set(session, tx);
this._attempt.set(undefined, tx);
this._autoConnected.set(undefined, tx);
this._connectAttempted.set(undefined, tx);
});
}

Expand Down Expand Up @@ -197,7 +206,10 @@ export class SessionRemoteConnection extends Disposable {

const statusBefore = session.remoteConnectionStatus?.get();
this._logService.info(`[SessionRemoteConnection] connect: starting ${provider.remoteAddress ?? provider.id}, statusBefore=${statusBefore?.kind}`);
this._attempt.set({ kind: 'active', session, message: undefined, statusBefore }, undefined);
transaction(tx => {
this._attempt.set({ kind: 'active', session, message: undefined, statusBefore }, tx);
this._connectAttempted.set(session, tx);
});
const remoteAddress = provider.remoteAddress;
if (remoteAddress && provider.onDidReportConnectProgress) {
this._progressListener.value = provider.onDidReportConnectProgress(progress => {
Expand Down Expand Up @@ -255,7 +267,26 @@ export class SessionRemoteConnection extends Disposable {
: status;
}

private _getRemoteHostConnectProgress(session: IActiveSession, status: SessionRemoteConnectionStatus | undefined, reader: IReader): string | undefined {
private _getConnectionLabels(provider: ISessionsProvider | undefined): IAgentHostConnectionLabels {
if (provider && isAgentHostProvider(provider) && provider.connectionLabels) {
return provider.connectionLabels;
}
const hostLabel = provider?.label ?? localize('sessionRemoteHost.unknown', "The remote host");
return {
unavailableTitle: localize('sessionRemoteHost.disconnectedTitle', "Cannot Connect to {0}", hostLabel),
unavailableDescription: localize('sessionRemoteHost.disconnected', "Cannot reach {0}.", hostLabel),
unavailable: localize('sessionRemoteHost.disconnected', "Cannot reach {0}.", hostLabel),
connectingTitle: localize('sessionRemoteHost.connectingTitle', "Connecting to {0}", hostLabel),
connectingDescription: localize('sessionRemoteHost.startingDescription', "Starting {0}.", hostLabel),
connecting: localize('sessionRemoteHost.waitingForConnection', "Waiting for agent host connection..."),
reconnecting: localize('sessionRemoteHost.reconnecting', "Reconnecting to {0}...", hostLabel),
reconnectingIn: seconds => localize('sessionRemoteHost.reconnectingIn', "Reconnecting to {0} in {1}s", hostLabel, seconds),
incompatibleTitle: localize('sessionRemoteHost.incompatibleTitle', "Cannot Connect to {0}", hostLabel),
incompatible: localize('sessionRemoteHost.incompatibleDescription', "{0} is incompatible with this version of Visual Studio Code.", hostLabel),
};
}

private _getRemoteHostConnectProgress(session: IActiveSession, status: SessionRemoteConnectionStatus | undefined, labels: IAgentHostConnectionLabels, reader: IReader): string | undefined {
const attempt = this._attempt.read(reader);
if (attempt?.kind !== 'active' || attempt.session !== session || status?.kind === 'connected') {
return undefined;
Expand All @@ -271,9 +302,7 @@ export class SessionRemoteConnection extends Disposable {
const settling = status?.kind === 'connecting'
|| status?.kind === 'reconnecting'
|| isSameRemoteConnectionStatus(status, attempt.statusBefore);
return settling
? localize('sessionRemoteHost.waitingForConnection', "Waiting for agent host connection...")
: undefined;
return settling ? labels.connecting : undefined;
}

private _getRemoteHostUnavailableContent(reader: IReader): IRemoteHostUnavailableEmptyStateContent | undefined {
Expand All @@ -285,16 +314,17 @@ export class SessionRemoteConnection extends Disposable {
const status = this._getEffectiveStatus(reader);
const provider = this._sessionsProvidersService.getProvider(session.providerId);
const hostLabel = provider?.label ?? localize('sessionRemoteHost.unknown', "The remote host");
const labels = this._getConnectionLabels(provider);
if (status?.kind === 'connected') {
return undefined;
}
if (status?.kind === 'incompatible') {
return {
title: localize('sessionRemoteHost.incompatibleTitle', "Cannot Connect to {0}", hostLabel),
description: localize('sessionRemoteHost.incompatibleDescription', "{0} is incompatible with this version of Visual Studio Code.", hostLabel),
title: labels.incompatibleTitle,
description: labels.incompatible,
};
}
const progressMessage = this._getRemoteHostConnectProgress(session, status, reader);
const progressMessage = this._getRemoteHostConnectProgress(session, status, labels, reader);
const autoConnectPending = this._autoConnectPending.read(reader);
const canStartHost = !!provider && isAgentHostProvider(provider) && !!provider.connect;
const autoConnect = canStartHost && provider.autoConnect
Expand All @@ -310,11 +340,12 @@ export class SessionRemoteConnection extends Disposable {
&& attempt.session === session
&& attempt.statusBefore?.kind === 'disconnected'
&& attempt.statusBefore.reason === SessionRemoteConnectionFailureReason.HostNotRunning);
if (progressMessage || autoConnectPending) {
// Include externally started connects; reconnecting keeps its delayed countdown banner.
if (progressMessage || autoConnectPending || status?.kind === 'connecting') {
return {
title: localize('sessionRemoteHost.connectingTitle', "Connecting to {0}", hostLabel),
description: localize('sessionRemoteHost.startingDescription', "Starting {0}.", hostLabel),
progress: progressMessage ?? localize('sessionRemoteHost.waitingForConnection', "Waiting for agent host connection..."),
title: labels.connectingTitle,
description: labels.connectingDescription,
progress: progressMessage ?? labels.connecting,
autoConnect: startedFromStoppedHost ? autoConnect : undefined,
};
}
Expand All @@ -335,14 +366,14 @@ export class SessionRemoteConnection extends Disposable {
};
}
return {
title: localize('sessionRemoteHost.disconnectedTitle', "Cannot Connect to {0}", hostLabel),
description: localize('sessionRemoteHost.disconnected', "Cannot reach {0}.", hostLabel),
title: labels.unavailableTitle,
description: labels.unavailableDescription,
// An unreachable host is often transient — a dropped tunnel, a sleeping
// machine — so offer a manual retry even though there is nothing local
// to start and so nothing to do automatically.
action: canStartHost
? {
label: localize('sessionRemoteHost.retry', "Retry"),
label: this._connectActionLabel(session, reader),
run: () => this.connect(),
}
: undefined,
Expand All @@ -358,17 +389,18 @@ export class SessionRemoteConnection extends Disposable {
const status = this._getEffectiveStatus(reader);
const provider = this._sessionsProvidersService.getProvider(session.providerId);
const hostLabel = provider?.label ?? localize('sessionRemoteHost.unknown', "The remote host");
const progressMessage = this._getRemoteHostConnectProgress(session, status, reader);
const labels = this._getConnectionLabels(provider);
const progressMessage = this._getRemoteHostConnectProgress(session, status, labels, reader);
// Mirror the recovery state: while an automatic start is pending the
// action must not appear, even for the frame before the attempt registers.
if (progressMessage || this._autoConnectPending.read(reader)) {
if (progressMessage || this._autoConnectPending.read(reader) || status?.kind === 'connecting') {
return {
icon: Codicon.sync,
message: progressMessage ?? localize('sessionRemoteHost.waitingForConnection', "Waiting for agent host connection..."),
message: progressMessage ?? labels.connecting,
};
}

if (!status || status.kind === 'connected' || status.kind === 'connecting') {
if (!status || status.kind === 'connected') {
return undefined;
}

Expand All @@ -377,7 +409,7 @@ export class SessionRemoteConnection extends Disposable {
// so this banner is the only place the incompatibility can be explained.
return {
icon: Codicon.debugDisconnect,
message: localize('sessionRemoteHost.incompatibleDescription', "{0} is incompatible with this version of Visual Studio Code.", hostLabel),
message: labels.incompatible,
};
}

Expand All @@ -386,13 +418,11 @@ export class SessionRemoteConnection extends Disposable {
return this._reconnectingBannerVisible.read(reader)
? {
icon: Codicon.sync,
message: seconds === undefined
? localize('sessionRemoteHost.reconnecting', "Reconnecting to {0}...", hostLabel)
: localize('sessionRemoteHost.reconnectingIn', "Reconnecting to {0} in {1}s", hostLabel, seconds),
message: seconds === undefined ? labels.reconnecting : labels.reconnectingIn(seconds),
// The banner is a live region, so the per-second countdown would
// otherwise queue an announcement every tick. Keep the spoken
// text stable and let only the visible text count down.
ariaLabel: localize('sessionRemoteHost.reconnecting', "Reconnecting to {0}...", hostLabel),
ariaLabel: labels.reconnecting,
action: seconds !== undefined && provider && isAgentHostProvider(provider) && provider.reconnectNow
? {
label: localize('sessionRemoteHost.tryNow', "Try Now"),
Expand All @@ -416,15 +446,27 @@ export class SessionRemoteConnection extends Disposable {
}
return {
icon: Codicon.debugDisconnect,
message: localize('sessionRemoteHost.disconnected', "Cannot reach {0}.", hostLabel),
message: labels.unavailable,
// A dropped tunnel or a sleeping machine is usually transient, so a
// manual retry is worth offering even with nothing local to start.
action: provider && isAgentHostProvider(provider) && provider.connect
? {
label: localize('sessionRemoteHost.retry', "Retry"),
label: this._connectActionLabel(session, reader),
run: () => this.connect(),
}
: undefined,
};
}

/**
* The wording for the action that establishes the connection. A host that has never been
* dialled from this view is offered a plain "Connect": presenting it as a retry would imply an
* attempt the user never made, and for hosts that must be resumed rather than merely reached,
* the first dial is a deliberate choice rather than a recovery.
*/
private _connectActionLabel(session: IActiveSession, reader: IReader): string {
return this._connectAttempted.read(reader) === session
? localize('sessionRemoteHost.retry', "Retry")
: localize('sessionRemoteHost.connect', "Connect");
}
}
17 changes: 17 additions & 0 deletions src/vs/sessions/common/agentHostSessionsProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ export interface IAgentHostAutoConnect {
setEnabled(enabled: boolean): void;
}

/** Localized labels shared by connection banners and recovery screens. */
export interface IAgentHostConnectionLabels {
readonly unavailableTitle: string;
readonly unavailableDescription?: string;
readonly unavailable: string;
readonly connectingTitle: string;
readonly connectingDescription?: string;
readonly connecting: string;
readonly reconnecting: string;
reconnectingIn(seconds: number): string;
readonly incompatibleTitle: string;
readonly incompatible: string;
}

/**
* Declares that a provider is one of many interchangeable members of a single
* user-facing host. Members collapse into one `IAgentHostFilterEntry` that
Expand Down Expand Up @@ -153,6 +167,9 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider {
*/
readonly autoConnect?: IAgentHostAutoConnect;

/** Optional labels for providers whose display name does not name the host. */
readonly connectionLabels?: IAgentHostConnectionLabels;

/**
* When `true`, the workspace picker keeps this provider's browse
* action(s) enabled even while {@link connectionStatus} reports
Expand Down
Loading