Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 3 additions & 1 deletion src/vs/sessions/contrib/chat/browser/chatView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,9 @@ export class ChatView extends AbstractChatView {
const draftLoaded = isEqual(this._loadedChatResource.read(reader), resource);
const failure = this._loadFailure.read(reader);
const loadStatus = failure && isEqual(failure.resource, resource) ? failure.status : undefined;
this._widget.setReadOnly(interactivity !== ChatInteractivity.Full || loading || modelReadOnly || !!loadStatus, !loadStatus && interactivity !== ChatInteractivity.ReadOnly && !modelReadOnly && (((draftOnly || loading) && draftLoaded) || this.isInputBlocked.read(reader)), loadStatus);
const keepInputVisible = !loadStatus && interactivity !== ChatInteractivity.ReadOnly
&& (this.isInputBlocked.read(reader) || (!modelReadOnly && (draftOnly || loading) && draftLoaded));
this._widget.setReadOnly(interactivity !== ChatInteractivity.Full || loading || modelReadOnly || !!loadStatus, keepInputVisible, loadStatus);
});

// Skip loading if we're already showing this chat
Expand Down
25 changes: 21 additions & 4 deletions src/vs/sessions/contrib/chat/test/browser/chatView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,7 @@ suite('Sessions - Chat View', () => {
const interactivity = observableValue('interactivity', ChatInteractivity.DraftOnly);
const liveModelReady = observableValue('liveModelReady', false);
const modelReadOnly = observableValue('modelReadOnly', false);
const inputBlocked = observableValue('inputBlocked', false);
const loading = observableValue('loading', false);
const loadedChatResource = observableValue<URI | undefined>('loadedChatResource', undefined);
const loadFailure = observableValue<{ resource: URI; status: IChatSessionHistoryStatus } | undefined>('loadFailure', undefined);
Expand All @@ -830,25 +831,39 @@ suite('Sessions - Chat View', () => {
_loadedChatResource: loadedChatResource,
_loadFailure: loadFailure,
historyStatus: constObservable(undefined),
isInputBlocked: constObservable(false),
isInputBlocked: inputBlocked,
chatPillsDebugService: { clear: () => { } },
_externalSessionBanner: { setSession: () => { } },
_chatPills: { setChat: () => { } },
_selectionSideChatController: { setChat: () => { } },
_banners: { setDebugData: () => { } },
_applyHistoryKey: () => { },
_widget: { setReadOnly: (readOnly: boolean, keepInputVisible: boolean, status?: IChatSessionHistoryStatus) => { inputState = { readOnly, keepInputVisible }; displayedHistoryStatus = status; } },
_widget: {
setReadOnly: (readOnly: boolean, keepInputVisible: boolean, status?: IChatSessionHistoryStatus) => {
inputState = { readOnly, keepInputVisible };
displayedHistoryStatus = status;
}
},
});
view.setChat(chat, undefined, session, () => attempts++);
const restoringSavedDraft = inputState;
view._handleDraftInput();
loadedChatResource.set(resource, undefined);
const offline = { inputState, attempts };
view._handleDraftInput();
transaction(tx => {
modelReadOnly.set(true, tx);
inputBlocked.set(true, tx);
});
const recordedHistory = inputState;
interactivity.set(ChatInteractivity.Full, undefined);
view.setChat(chat, undefined, session);
const waitingForLiveContent = inputState;
liveModelReady.set(true, undefined);
transaction(tx => {
modelReadOnly.set(false, tx);
inputBlocked.set(false, tx);
liveModelReady.set(true, tx);
});
const ready = inputState;
loadFailure.set({ resource, status: { kind: 'history', message: 'Saved history is unavailable' } }, undefined);
const failedLoad = { inputState, message: displayedHistoryStatus?.message };
Expand All @@ -858,13 +873,15 @@ suite('Sessions - Chat View', () => {
modelReadOnly.set(false, undefined);
loading.set(true, undefined);
const authenticating = inputState;
inputBlocked.set(true, undefined);
interactivity.set(ChatInteractivity.ReadOnly, undefined);
view.setChat(chat, undefined, session);
view._handleDraftInput();

assert.deepStrictEqual({ restoringSavedDraft, offline, waitingForLiveContent, ready, failedLoad, authenticating, revoked, archived: inputState, attempts }, {
assert.deepStrictEqual({ restoringSavedDraft, offline, recordedHistory, waitingForLiveContent, ready, failedLoad, authenticating, revoked, archived: inputState, attempts }, {
restoringSavedDraft: { readOnly: true, keepInputVisible: false },
offline: { inputState: { readOnly: true, keepInputVisible: true }, attempts: 0 },
recordedHistory: { readOnly: true, keepInputVisible: true },
waitingForLiveContent: { readOnly: true, keepInputVisible: true },
ready: { readOnly: false, keepInputVisible: false },
failedLoad: { inputState: { readOnly: true, keepInputVisible: false }, message: 'Saved history is unavailable' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ In the Agents Window, renaming a discovered sandbox session updates its owning M

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.

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.
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.

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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1155,7 +1155,7 @@ suite('CloudSandboxAgentHostContribution', () => {
const provider = harness.contentProviders.get(sessionType)!;
const resource = URI.from({ scheme: sessionType, path: '/sess-1' });
const session = store.add(await provider.provideChatSessionContent(resource, CancellationToken.None));
const before = { readOnly: session.isReadOnly?.get(), connected: [...harness.connectedTo], status: session.historyStatus?.get() };
const before = { readOnly: session.isReadOnly?.get(), blocked: session.isInputBlocked?.get(), connected: [...harness.connectedTo], status: session.historyStatus?.get() };
const live = store.add(new ReadOnlyChatSession(resource, [{
id: 'turn', type: 'request', prompt: 'Live history', participant: sessionType,
}], 'Live title', constObservable(false)));
Expand All @@ -1168,10 +1168,10 @@ suite('CloudSandboxAgentHostContribution', () => {
activated, before, sameProvider: harness.contentProviders.get(sessionType) === provider,
independentReference: reference !== session,
sameSession: reference.isReadOnly === session.isReadOnly && reference.history === session.history,
readOnly: session.isReadOnly?.get(), history: session.history, historyRequests: harness.historyRequests,
readOnly: session.isReadOnly?.get(), blocked: session.isInputBlocked?.get(), history: session.history, historyRequests: harness.historyRequests,
}, {
activated: true, before: { readOnly: true, connected: ['env-1'], status: undefined }, sameProvider: true,
independentReference: true, sameSession: true, readOnly: false, history: live.history, historyRequests: ['task-1'],
activated: true, before: { readOnly: true, blocked: true, connected: ['env-1'], status: undefined }, sameProvider: true,
independentReference: true, sameSession: true, readOnly: false, blocked: false, history: live.history, historyRequests: ['task-1'],
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ class PromotableCloudSandboxChatSession extends Disposable implements IChatSessi
const progress = source.progressObs?.read(reader);
const complete = source.isCompleteObs?.read(reader);
const readOnly = source.isReadOnly?.read(reader) ?? false;
const blocked = source.isInputBlocked?.read(reader) ?? false;
const blocked = !live || (source.isInputBlocked?.read(reader) ?? false);
const backgroundShellCount = source.backgroundShellCount?.read(reader);
const canvasContext = source.canvasContext?.read(reader);
if (live && (previousProgress !== progress || previousComplete !== complete)) {
Expand Down
21 changes: 15 additions & 6 deletions src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ import { IChatTodoListService } from '../../common/tools/chatTodoListService.js'
import { ChatRequestVariableSet, IChatRequestTranscriptContextVariableEntry, IChatRequestVariableEntry, isPastedTextArtifact, isPromptFileVariableEntry, isPromptTextVariableEntry, isWorkspaceVariableEntry, PromptFileVariableKind, toPromptFileVariableEntry } from '../../common/attachments/chatVariableEntries.js';
import { ChatViewModel, IChatResponseViewModel, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js';
import { ChatMessageRole, IChatMessage } from '../../common/languageModels.js';
import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, CONTINUE_CHAT_IN_COPILOT_ACTION_ID, IResolvedNewChatSessionType, isLocalChatSessionSubjectToManagedPolicy, managedPolicyRequiresAgentHostMessage, ThinkingDisplayMode } from '../../common/constants.js';
import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, CONTINUE_CHAT_IN_COPILOT_ACTION_ID, getAgentHostSessionResource, IResolvedNewChatSessionType, isLocalChatSessionSubjectToManagedPolicy, managedPolicyRequiresAgentHostMessage, ThinkingDisplayMode } from '../../common/constants.js';
import { IManagedSettingsService, requiresCopilotAgentHost } from '../../../../../platform/policy/common/copilotManagedSettings.js';
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
import { MarshalledId } from '../../../../../base/common/marshallingIds.js';
Expand Down Expand Up @@ -2151,8 +2151,13 @@ export class ChatWidget extends Disposable implements IChatWidget {
$mid: MarshalledId.ChatViewContext, sessionResource: resource, inputUri: this.input.inputUri,
}),
} : historyStatus?.action);
this.readOnlyBanner?.setVisible(readOnly && !keepInputVisible);
this.setInputVisible(!readOnly || keepInputVisible);
const wasBannerVisible = this.readOnlyBanner?.visible;
this.readOnlyBanner?.setVisible(readOnly && (!keepInputVisible || !!historyStatus));
const inputVisible = !readOnly || keepInputVisible;
if (this._inputVisible === inputVisible && wasBannerVisible !== this.readOnlyBanner?.visible) {
this._layoutListForInputHeight();
}
this.setInputVisible(inputVisible);
// Authoritative over the lock/unlock `editable` toggles below.
this._applyRendererEditable(!readOnly);
this.rerenderListIfConnected();
Expand Down Expand Up @@ -2184,15 +2189,19 @@ export class ChatWidget extends Disposable implements IChatWidget {
}

private observeSessionArchived(model: IChatModel): IObservable<boolean> {
return isLocalChatSessionSubjectToManagedPolicy(model.sessionResource, model.initialLocation) || model.sessionResource.scheme === 'agent-host-claude' || model.sessionResource.scheme === 'agent-host-codex'
? observableFromEvent(this, this.agentSessionsService.model.onDidChangeSessions, () => this.agentSessionsService.getSession(model.sessionResource)?.isArchived() === true)
const agentHost = isAgentHostTarget(getChatSessionType(model.sessionResource));
return isLocalChatSessionSubjectToManagedPolicy(model.sessionResource, model.initialLocation) || agentHost
? observableFromEvent(this, this.agentSessionsService.model.onDidChangeSessions, () =>
this.agentSessionsService.getSession(model.sessionResource)?.isArchived() === true
|| (agentHost && this.agentSessionsService.getSession(getAgentHostSessionResource(model.sessionResource))?.isArchived() === true))
: constObservable(false);
}

private updateReadOnlyState(model: IChatModel, archived: boolean, reader: IReader): void {
const policyRequired = isLocalChatSessionSubjectToManagedPolicy(model.sessionResource, model.initialLocation) && this.policyRequiresAgentHost.read(reader);
this.policyReadOnly = policyRequired && !archived;
this.setReadOnly(model.isReadOnly.read(reader) || policyRequired, !policyRequired && model.isInputBlocked.read(reader), model.historyStatus?.read(reader));
const readOnly = model.isReadOnly.read(reader) || policyRequired || (archived && isAgentHostTarget(getChatSessionType(model.sessionResource)));
this.setReadOnly(readOnly, !policyRequired && !archived && model.isInputBlocked.read(reader), model.historyStatus?.read(reader));
}

private autoMigratePolicySession(model: IChatModel, archived: IObservable<boolean>): IDisposable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,35 @@ suite('CloudSandboxSessionHandler', () => {
});
});

test('keeps recorded input blocked through cache refresh until live content is ready', async () => {
const fresh = new DeferredPromise<IReplayedTaskHistory>();
const pendingLive = new DeferredPromise<IChatSession>();
const connection = observableValue<RemoteAgentHostConnectionStatus>('connection', RemoteAgentHostConnectionStatus.connecting);
const handler = createHandler((_token, _diagnosticId, onCachedHistory) => {
onCachedHistory?.(recordedHistory());
return fresh.p;
}, undefined, undefined, undefined, undefined, connection);
const session = store.add(await handler.provideChatSessionContent(resource, CancellationToken.None));
const inputState = () => ({ readOnly: session.isReadOnly?.get(), blocked: session.isInputBlocked?.get() });
const cached = inputState();
const historyChanged = Event.toPromise(session.onDidChangeHistory!);
await fresh.complete(recordedHistory('Refreshed title'));
await historyChanged;
const refreshed = inputState();
connection.set(RemoteAgentHostConnectionStatus.connected, undefined);
handler.setLiveProvider({ provideChatSessionContent: () => pendingLive.p });
const waitingForLiveContent = inputState();
await pendingLive.complete(store.add(new LiveSession()));
await waitForState(session.isInputBlocked!, value => !value);

assert.deepStrictEqual({ cached, refreshed, waitingForLiveContent, live: inputState() }, {
cached: { readOnly: true, blocked: true },
refreshed: { readOnly: true, blocked: true },
waitingForLiveContent: { readOnly: true, blocked: true },
live: { readOnly: false, blocked: false },
});
});

test('a cached peer miss waits for fresh history instead of showing the default conversation', async () => {
const fresh = new DeferredPromise<IReplayedTaskHistory>();
const cached = recordedHistory();
Expand Down Expand Up @@ -656,7 +685,8 @@ suite('CloudSandboxSessionHandler', () => {
assert.deepStrictEqual({
prompts: session.history.filter(item => item.type === 'request').map(item => item.prompt),
error: session.history.find(item => item.type === 'response')?.errorDetails?.message,
}, { prompts: [''], error: `HTTP ${status}` });
readOnly: session.isReadOnly?.get(), blocked: session.isInputBlocked?.get(),
}, { prompts: [''], error: `HTTP ${status}`, readOnly: true, blocked: true });
});
}

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

test('a late live result is disposed after its recorded session is closed', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2129,7 +2129,7 @@ suite('Editor cloud sandbox discovery', () => {
});
});

test('opens original offline history without waking or replacing the sandbox', async () => {
test('opens original offline history for drafting without waking or replacing the sandbox', async () => {
const h = createHarness(store);
await h.refresh();
await h.contribution.activate();
Expand All @@ -2138,12 +2138,13 @@ suite('Editor cloud sandbox discovery', () => {
resource: session.sessionResource.toString(),
title: session.title,
readOnly: session.isReadOnly?.get(),
blocked: session.isInputBlocked?.get(),
requests: session.history.filter(item => item.type === 'request').map(item => item.prompt),
history: h.calls.history,
connected: h.calls.connected,
created: h.calls.created,
}, {
resource: resource.toString(), title: discovered.name, readOnly: true,
resource: resource.toString(), title: discovered.name, readOnly: true, blocked: true,
requests: ['Original request'], history: [discovered.taskId], connected: [], created: 0,
});
});
Expand Down
Loading
Loading