Skip to content

Commit 2ea40b5

Browse files
osortegaCopilot
andauthored
Keep sandbox chat input visible while connecting (#343023)
* Keep sandbox chat input visible while connecting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix sandbox composer recovery layout and archive lookup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e5a32e2 commit 2ea40b5

9 files changed

Lines changed: 244 additions & 25 deletions

File tree

‎src/vs/sessions/contrib/chat/browser/chatView.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -689,7 +689,9 @@ export class ChatView extends AbstractChatView {
689689
const draftLoaded = isEqual(this._loadedChatResource.read(reader), resource);
690690
const failure = this._loadFailure.read(reader);
691691
const loadStatus = failure && isEqual(failure.resource, resource) ? failure.status : undefined;
692-
this._widget.setReadOnly(interactivity !== ChatInteractivity.Full || loading || modelReadOnly || !!loadStatus, !loadStatus && interactivity !== ChatInteractivity.ReadOnly && !modelReadOnly && (((draftOnly || loading) && draftLoaded) || this.isInputBlocked.read(reader)), loadStatus);
692+
const keepInputVisible = !loadStatus && interactivity !== ChatInteractivity.ReadOnly
693+
&& (this.isInputBlocked.read(reader) || (!modelReadOnly && (draftOnly || loading) && draftLoaded));
694+
this._widget.setReadOnly(interactivity !== ChatInteractivity.Full || loading || modelReadOnly || !!loadStatus, keepInputVisible, loadStatus);
693695
});
694696

695697
// Skip loading if we're already showing this chat

‎src/vs/sessions/contrib/chat/test/browser/chatView.test.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,7 @@ suite('Sessions - Chat View', () => {
805805
const interactivity = observableValue('interactivity', ChatInteractivity.DraftOnly);
806806
const liveModelReady = observableValue('liveModelReady', false);
807807
const modelReadOnly = observableValue('modelReadOnly', false);
808+
const inputBlocked = observableValue('inputBlocked', false);
808809
const loading = observableValue('loading', false);
809810
const loadedChatResource = observableValue<URI | undefined>('loadedChatResource', undefined);
810811
const loadFailure = observableValue<{ resource: URI; status: IChatSessionHistoryStatus } | undefined>('loadFailure', undefined);
@@ -830,25 +831,39 @@ suite('Sessions - Chat View', () => {
830831
_loadedChatResource: loadedChatResource,
831832
_loadFailure: loadFailure,
832833
historyStatus: constObservable(undefined),
833-
isInputBlocked: constObservable(false),
834+
isInputBlocked: inputBlocked,
834835
chatPillsDebugService: { clear: () => { } },
835836
_externalSessionBanner: { setSession: () => { } },
836837
_chatPills: { setChat: () => { } },
837838
_selectionSideChatController: { setChat: () => { } },
838839
_banners: { setDebugData: () => { } },
839840
_applyHistoryKey: () => { },
840-
_widget: { setReadOnly: (readOnly: boolean, keepInputVisible: boolean, status?: IChatSessionHistoryStatus) => { inputState = { readOnly, keepInputVisible }; displayedHistoryStatus = status; } },
841+
_widget: {
842+
setReadOnly: (readOnly: boolean, keepInputVisible: boolean, status?: IChatSessionHistoryStatus) => {
843+
inputState = { readOnly, keepInputVisible };
844+
displayedHistoryStatus = status;
845+
}
846+
},
841847
});
842848
view.setChat(chat, undefined, session, () => attempts++);
843849
const restoringSavedDraft = inputState;
844850
view._handleDraftInput();
845851
loadedChatResource.set(resource, undefined);
846852
const offline = { inputState, attempts };
847853
view._handleDraftInput();
854+
transaction(tx => {
855+
modelReadOnly.set(true, tx);
856+
inputBlocked.set(true, tx);
857+
});
858+
const recordedHistory = inputState;
848859
interactivity.set(ChatInteractivity.Full, undefined);
849860
view.setChat(chat, undefined, session);
850861
const waitingForLiveContent = inputState;
851-
liveModelReady.set(true, undefined);
862+
transaction(tx => {
863+
modelReadOnly.set(false, tx);
864+
inputBlocked.set(false, tx);
865+
liveModelReady.set(true, tx);
866+
});
852867
const ready = inputState;
853868
loadFailure.set({ resource, status: { kind: 'history', message: 'Saved history is unavailable' } }, undefined);
854869
const failedLoad = { inputState, message: displayedHistoryStatus?.message };
@@ -858,13 +873,15 @@ suite('Sessions - Chat View', () => {
858873
modelReadOnly.set(false, undefined);
859874
loading.set(true, undefined);
860875
const authenticating = inputState;
876+
inputBlocked.set(true, undefined);
861877
interactivity.set(ChatInteractivity.ReadOnly, undefined);
862878
view.setChat(chat, undefined, session);
863879
view._handleDraftInput();
864880

865-
assert.deepStrictEqual({ restoringSavedDraft, offline, waitingForLiveContent, ready, failedLoad, authenticating, revoked, archived: inputState, attempts }, {
881+
assert.deepStrictEqual({ restoringSavedDraft, offline, recordedHistory, waitingForLiveContent, ready, failedLoad, authenticating, revoked, archived: inputState, attempts }, {
866882
restoringSavedDraft: { readOnly: true, keepInputVisible: false },
867883
offline: { inputState: { readOnly: true, keepInputVisible: true }, attempts: 0 },
884+
recordedHistory: { readOnly: true, keepInputVisible: true },
868885
waitingForLiveContent: { readOnly: true, keepInputVisible: true },
869886
ready: { readOnly: false, keepInputVisible: false },
870887
failedLoad: { inputState: { readOnly: true, keepInputVisible: false }, message: 'Saved history is unavailable' },

‎src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ In the Agents Window, renaming a discovered sandbox session updates its owning M
3030

3131
In the Agents Window, opening or restoring a sandbox chat starts connecting or waking its environment in the background by default. Disabling `chat.agentHost.cloudSandbox.autoConnectOnOpen` leaves the environment disconnected until the user starts composing or explicitly connects. Both provider open preparation and content activation honor this preference; it does not cancel existing connections or prevent explicit attempts. Provider open preparation starts the connection without blocking, including for cached chats that skip content activation. Chat content activation shares that attempt and serves cached or recorded history independently. A history-backed content provider retains the session resource and chat model through its first live attachment; later live snapshots promote recorded history in place. Recorded peer and subagent transcripts follow the requested chat identity. Later live-provider replacement retains the existing reload lifecycle.
3232

33-
Normally interactive chats retain an editable local draft after their saved input has loaded. Connection progress and live-content failures remain hidden until the user types, pastes, or attaches context; input joins the existing attempt rather than starting another. Failed attempts retain the draft and require explicit Retry. Sending waits for the live chat model, and session settings wait for the authenticated connection. Archived and host-read-only chats remain read-only. Connecting never submits a draft automatically. The Editor Window retains its online-only connection-on-open policy without an offline-draft handoff.
33+
Normally interactive chats retain an editable local draft after their saved input has loaded. Connection progress and live-content failures remain hidden until the user types, pastes, or attaches context; input joins the existing attempt rather than starting another. Failed attempts retain the draft and require explicit Retry. Sending waits for the live chat model, and session settings wait for the authenticated connection. Archived and host-read-only chats remain read-only. Connecting never submits a draft automatically. The Editor Window retains its online-only connection-on-open policy, but keeps offline drafts editable while sending is unavailable.
3434

3535
The window's Mission Control API service owns a bounded, account- and task-scoped memory cache of replayed history, independent of chat model disposal. Cached previews refresh silently. If recorded retrieval fails and live content cannot replace the preview, the existing status banner offers Refresh, which retries only the history API without waking compute or replacing the model. Connection feedback takes precedence when interaction requires it. Missing or incomplete refresh data cannot erase an already displayed transcript; authorization loss or a missing task instead surfaces the access error. Live attachment preserves cached data only when the transcript matches; differences and observed live changes invalidate it, and superseded in-flight reads cannot refill it. Authentication changes, feature teardown, task removal, and service disposal clear the relevant cache and cancel pending reads. Concurrent readers share a task read with independent cancellation. The cache stores neither credentials, raw event streams, nor live models, and survives neither restart nor account changes.
3636

‎src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,7 +1180,7 @@ suite('CloudSandboxAgentHostContribution', () => {
11801180
const provider = harness.contentProviders.get(sessionType)!;
11811181
const resource = URI.from({ scheme: sessionType, path: '/sess-1' });
11821182
const session = store.add(await provider.provideChatSessionContent(resource, CancellationToken.None));
1183-
const before = { readOnly: session.isReadOnly?.get(), connected: [...harness.connectedTo], status: session.historyStatus?.get() };
1183+
const before = { readOnly: session.isReadOnly?.get(), blocked: session.isInputBlocked?.get(), connected: [...harness.connectedTo], status: session.historyStatus?.get() };
11841184
const live = store.add(new ReadOnlyChatSession(resource, [{
11851185
id: 'turn', type: 'request', prompt: 'Live history', participant: sessionType,
11861186
}], 'Live title', constObservable(false)));
@@ -1193,10 +1193,10 @@ suite('CloudSandboxAgentHostContribution', () => {
11931193
activated, before, sameProvider: harness.contentProviders.get(sessionType) === provider,
11941194
independentReference: reference !== session,
11951195
sameSession: reference.isReadOnly === session.isReadOnly && reference.history === session.history,
1196-
readOnly: session.isReadOnly?.get(), history: session.history, historyRequests: harness.historyRequests,
1196+
readOnly: session.isReadOnly?.get(), blocked: session.isInputBlocked?.get(), history: session.history, historyRequests: harness.historyRequests,
11971197
}, {
1198-
activated: true, before: { readOnly: true, connected: ['env-1'], status: undefined }, sameProvider: true,
1199-
independentReference: true, sameSession: true, readOnly: false, history: live.history, historyRequests: ['task-1'],
1198+
activated: true, before: { readOnly: true, blocked: true, connected: ['env-1'], status: undefined }, sameProvider: true,
1199+
independentReference: true, sameSession: true, readOnly: false, blocked: false, history: live.history, historyRequests: ['task-1'],
12001200
});
12011201
});
12021202

‎src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionHandler.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ class PromotableCloudSandboxChatSession extends Disposable implements IChatSessi
134134
const progress = source.progressObs?.read(reader);
135135
const complete = source.isCompleteObs?.read(reader);
136136
const readOnly = source.isReadOnly?.read(reader) ?? false;
137-
const blocked = source.isInputBlocked?.read(reader) ?? false;
137+
const blocked = !live || (source.isInputBlocked?.read(reader) ?? false);
138138
const backgroundShellCount = source.backgroundShellCount?.read(reader);
139139
const canvasContext = source.canvasContext?.read(reader);
140140
if (live && (previousProgress !== progress || previousComplete !== complete)) {

‎src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ import { IChatTodoListService } from '../../common/tools/chatTodoListService.js'
8181
import { ChatRequestVariableSet, IChatRequestTranscriptContextVariableEntry, IChatRequestVariableEntry, isPastedTextArtifact, isPromptFileVariableEntry, isPromptTextVariableEntry, isWorkspaceVariableEntry, PromptFileVariableKind, toPromptFileVariableEntry } from '../../common/attachments/chatVariableEntries.js';
8282
import { ChatViewModel, IChatResponseViewModel, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js';
8383
import { ChatMessageRole, IChatMessage } from '../../common/languageModels.js';
84-
import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, CONTINUE_CHAT_IN_COPILOT_ACTION_ID, IResolvedNewChatSessionType, isLocalChatSessionSubjectToManagedPolicy, managedPolicyRequiresAgentHostMessage, ThinkingDisplayMode } from '../../common/constants.js';
84+
import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, CONTINUE_CHAT_IN_COPILOT_ACTION_ID, getAgentHostSessionResource, IResolvedNewChatSessionType, isLocalChatSessionSubjectToManagedPolicy, managedPolicyRequiresAgentHostMessage, ThinkingDisplayMode } from '../../common/constants.js';
8585
import { IManagedSettingsService, requiresCopilotAgentHost } from '../../../../../platform/policy/common/copilotManagedSettings.js';
8686
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
8787
import { MarshalledId } from '../../../../../base/common/marshallingIds.js';
@@ -2156,8 +2156,13 @@ export class ChatWidget extends Disposable implements IChatWidget {
21562156
$mid: MarshalledId.ChatViewContext, sessionResource: resource, inputUri: this.input.inputUri,
21572157
}),
21582158
} : historyStatus?.action);
2159-
this.readOnlyBanner?.setVisible(readOnly && !keepInputVisible);
2160-
this.setInputVisible(!readOnly || keepInputVisible);
2159+
const wasBannerVisible = this.readOnlyBanner?.visible;
2160+
this.readOnlyBanner?.setVisible(readOnly && (!keepInputVisible || !!historyStatus));
2161+
const inputVisible = !readOnly || keepInputVisible;
2162+
if (this._inputVisible === inputVisible && wasBannerVisible !== this.readOnlyBanner?.visible) {
2163+
this._layoutListForInputHeight();
2164+
}
2165+
this.setInputVisible(inputVisible);
21612166
// Authoritative over the lock/unlock `editable` toggles below.
21622167
this._applyRendererEditable(!readOnly);
21632168
this.rerenderListIfConnected();
@@ -2189,15 +2194,19 @@ export class ChatWidget extends Disposable implements IChatWidget {
21892194
}
21902195

21912196
private observeSessionArchived(model: IChatModel): IObservable<boolean> {
2192-
return isLocalChatSessionSubjectToManagedPolicy(model.sessionResource, model.initialLocation) || model.sessionResource.scheme === 'agent-host-claude' || model.sessionResource.scheme === 'agent-host-codex'
2193-
? observableFromEvent(this, this.agentSessionsService.model.onDidChangeSessions, () => this.agentSessionsService.getSession(model.sessionResource)?.isArchived() === true)
2197+
const agentHost = isAgentHostTarget(getChatSessionType(model.sessionResource));
2198+
return isLocalChatSessionSubjectToManagedPolicy(model.sessionResource, model.initialLocation) || agentHost
2199+
? observableFromEvent(this, this.agentSessionsService.model.onDidChangeSessions, () =>
2200+
this.agentSessionsService.getSession(model.sessionResource)?.isArchived() === true
2201+
|| (agentHost && this.agentSessionsService.getSession(getAgentHostSessionResource(model.sessionResource))?.isArchived() === true))
21942202
: constObservable(false);
21952203
}
21962204

21972205
private updateReadOnlyState(model: IChatModel, archived: boolean, reader: IReader): void {
21982206
const policyRequired = isLocalChatSessionSubjectToManagedPolicy(model.sessionResource, model.initialLocation) && this.policyRequiresAgentHost.read(reader);
21992207
this.policyReadOnly = policyRequired && !archived;
2200-
this.setReadOnly(model.isReadOnly.read(reader) || policyRequired, !policyRequired && model.isInputBlocked.read(reader), model.historyStatus?.read(reader));
2208+
const readOnly = model.isReadOnly.read(reader) || policyRequired || (archived && isAgentHostTarget(getChatSessionType(model.sessionResource)));
2209+
this.setReadOnly(readOnly, !policyRequired && !archived && model.isInputBlocked.read(reader), model.historyStatus?.read(reader));
22012210
}
22022211

22032212
private autoMigratePolicySession(model: IChatModel, archived: IObservable<boolean>): IDisposable {

‎src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxSessionHandler.test.ts‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,35 @@ suite('CloudSandboxSessionHandler', () => {
287287
});
288288
});
289289

290+
test('keeps recorded input blocked through cache refresh until live content is ready', async () => {
291+
const fresh = new DeferredPromise<IReplayedTaskHistory>();
292+
const pendingLive = new DeferredPromise<IChatSession>();
293+
const connection = observableValue<RemoteAgentHostConnectionStatus>('connection', RemoteAgentHostConnectionStatus.connecting);
294+
const handler = createHandler((_token, _diagnosticId, onCachedHistory) => {
295+
onCachedHistory?.(recordedHistory());
296+
return fresh.p;
297+
}, undefined, undefined, undefined, undefined, connection);
298+
const session = store.add(await handler.provideChatSessionContent(resource, CancellationToken.None));
299+
const inputState = () => ({ readOnly: session.isReadOnly?.get(), blocked: session.isInputBlocked?.get() });
300+
const cached = inputState();
301+
const historyChanged = Event.toPromise(session.onDidChangeHistory!);
302+
await fresh.complete(recordedHistory('Refreshed title'));
303+
await historyChanged;
304+
const refreshed = inputState();
305+
connection.set(RemoteAgentHostConnectionStatus.connected, undefined);
306+
handler.setLiveProvider({ provideChatSessionContent: () => pendingLive.p });
307+
const waitingForLiveContent = inputState();
308+
await pendingLive.complete(store.add(new LiveSession()));
309+
await waitForState(session.isInputBlocked!, value => !value);
310+
311+
assert.deepStrictEqual({ cached, refreshed, waitingForLiveContent, live: inputState() }, {
312+
cached: { readOnly: true, blocked: true },
313+
refreshed: { readOnly: true, blocked: true },
314+
waitingForLiveContent: { readOnly: true, blocked: true },
315+
live: { readOnly: false, blocked: false },
316+
});
317+
});
318+
290319
test('a cached peer miss waits for fresh history instead of showing the default conversation', async () => {
291320
const fresh = new DeferredPromise<IReplayedTaskHistory>();
292321
const cached = recordedHistory();
@@ -656,7 +685,8 @@ suite('CloudSandboxSessionHandler', () => {
656685
assert.deepStrictEqual({
657686
prompts: session.history.filter(item => item.type === 'request').map(item => item.prompt),
658687
error: session.history.find(item => item.type === 'response')?.errorDetails?.message,
659-
}, { prompts: [''], error: `HTTP ${status}` });
688+
readOnly: session.isReadOnly?.get(), blocked: session.isInputBlocked?.get(),
689+
}, { prompts: [''], error: `HTTP ${status}`, readOnly: true, blocked: true });
660690
});
661691
}
662692

@@ -933,7 +963,7 @@ suite('CloudSandboxSessionHandler', () => {
933963
live.isReadOnly.set(true, undefined);
934964
handler.setLiveProvider({ provideChatSessionContent: async () => live });
935965
await timeout(0);
936-
assert.deepStrictEqual({ readOnly: session.isReadOnly?.get(), history: session.history }, { readOnly: true, history: live.history });
966+
assert.deepStrictEqual({ readOnly: session.isReadOnly?.get(), blocked: session.isInputBlocked?.get(), history: session.history }, { readOnly: true, blocked: false, history: live.history });
937967
});
938968

939969
test('a late live result is disposed after its recorded session is closed', async () => {

‎src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2157,7 +2157,7 @@ suite('Editor cloud sandbox discovery', () => {
21572157
});
21582158
});
21592159

2160-
test('opens original offline history without waking or replacing the sandbox', async () => {
2160+
test('opens original offline history for drafting without waking or replacing the sandbox', async () => {
21612161
const h = createHarness(store);
21622162
await h.refresh();
21632163
await h.contribution.activate();
@@ -2166,12 +2166,13 @@ suite('Editor cloud sandbox discovery', () => {
21662166
resource: session.sessionResource.toString(),
21672167
title: session.title,
21682168
readOnly: session.isReadOnly?.get(),
2169+
blocked: session.isInputBlocked?.get(),
21692170
requests: session.history.filter(item => item.type === 'request').map(item => item.prompt),
21702171
history: h.calls.history,
21712172
connected: h.calls.connected,
21722173
created: h.calls.created,
21732174
}, {
2174-
resource: resource.toString(), title: discovered.name, readOnly: true,
2175+
resource: resource.toString(), title: discovered.name, readOnly: true, blocked: true,
21752176
requests: ['Original request'], history: [discovered.taskId], connected: [], created: 0,
21762177
});
21772178
});

0 commit comments

Comments
 (0)