diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 4a01c9adde82e4..d349088eeca62d 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -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 diff --git a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts index 58827df58038ba..766d9b1f31dc2c 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -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('loadedChatResource', undefined); const loadFailure = observableValue<{ resource: URI; status: IChatSessionHistoryStatus } | undefined>('loadFailure', undefined); @@ -830,14 +831,19 @@ 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; @@ -845,10 +851,19 @@ suite('Sessions - Chat View', () => { 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 }; @@ -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' }, diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md index 3424403668393c..b2e550436b3989 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md @@ -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. diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts index f0d3113bde1e43..3e39feb49c5433 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts @@ -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))); @@ -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'], }); }); diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionHandler.ts index 3b18ae7f5b9841..c0468aa1393259 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionHandler.ts @@ -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)) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 7632d2667500c2..f66eeea7c18886 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -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'; @@ -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(); @@ -2184,15 +2189,19 @@ export class ChatWidget extends Disposable implements IChatWidget { } private observeSessionArchived(model: IChatModel): IObservable { - 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): IDisposable { diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxSessionHandler.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxSessionHandler.test.ts index 3bcb25ce1d1464..28e53f52953eed 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxSessionHandler.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxSessionHandler.test.ts @@ -287,6 +287,35 @@ suite('CloudSandboxSessionHandler', () => { }); }); + test('keeps recorded input blocked through cache refresh until live content is ready', async () => { + const fresh = new DeferredPromise(); + const pendingLive = new DeferredPromise(); + const connection = observableValue('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(); const cached = recordedHistory(); @@ -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 }); }); } @@ -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 () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts index 85f3813fe14664..5d4c6429fe1b7d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts @@ -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(); @@ -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, }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts index 8d5a7abf514d3b..c4f94934387cb4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts @@ -10,6 +10,7 @@ import { DeferredPromise, timeout } from '../../../../../../base/common/async.js import { CancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../../../base/common/map.js'; import { autorun, constObservable, IObservable, IReader, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { mockObject, upcastPartial } from '../../../../../../base/test/common/mock.js'; @@ -65,7 +66,8 @@ import { ChatRequestParser } from '../../../common/requestParser/chatRequestPars import { ToolAndToolSetEnablementMap } from '../../../common/tools/languageModelToolsService.js'; import { observePromptTimelineHostWidth } from '../../../browser/promptTimeline/promptTimelineWidgetContrib.js'; import { ChatContentMarkdownRenderer } from '../../../browser/widget/chatContentMarkdownRenderer.js'; -import { ChatReadOnlyBanner } from '../../../browser/widget/chatReadOnlyBanner.js'; +import { CHAT_READ_ONLY_BANNER_HEIGHT, ChatReadOnlyBanner } from '../../../browser/widget/chatReadOnlyBanner.js'; +import { getSubagentEditorResource } from '../../../browser/widget/chatContentParts/chatSubagentOpenChat.js'; import { IChatViewWelcomeContent } from '../../../browser/viewsWelcome/chatViewWelcomeController.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { createChatUserInteractionTestHarness } from '../chatUserInteractionTestUtils.js'; @@ -1349,6 +1351,7 @@ suite('ChatWidget', () => { function createRerenderWidget() { const domNode = mainWindow.document.createElement('div'); let rerenders = 0; + const layoutHeights: number[] = []; const discardingSlot = { set value(disposable: IDisposable | undefined) { disposable?.dispose(); } }; const widget: ChatWidget = Object.assign(Object.create(ChatWidget.prototype), { _readOnly: false, @@ -1378,11 +1381,11 @@ suite('ChatWidget', () => { lastItem: undefined, updateRendererOptions: () => { }, setVisible: () => { }, - layout: () => { }, + layout: (height: number) => layoutHeights.push(height), rerender: () => rerenders++, }, }); - return { widget, domNode, rerenders: () => rerenders }; + return { widget, domNode, layoutHeights, rerenders: () => rerenders }; } test('passes read-only transitions to the renderer independently of request editing', () => { @@ -1416,6 +1419,163 @@ suite('ChatWidget', () => { }); }); + test('keeps the sandbox composer visible while live content is unavailable', () => { + const container = dom.append(mainWindow.document.body, dom.$('.interactive-session')); + store.add(toDisposable(() => container.remove())); + const inputContainer = dom.append(container, dom.$('.interactive-input-part')); + const input = dom.append(inputContainer, dom.$('textarea')); + const transcript = dom.append(container, dom.$('div')); + transcript.tabIndex = 0; + const readOnly = observableValue('readOnly', true); + const blocked = observableValue('blocked', true); + let archived = false; + const sessionChanges = store.add(new Emitter()); + const model = upcastPartial({ + sessionResource: URI.parse('remote-agent-host-test:/session'), + isReadOnly: readOnly, + isInputBlocked: blocked, + }); + const draftOnly = observableValue('draftOnly', false); + const widget: { + observeSessionArchived(model: IChatModel): IObservable; + updateReadOnlyState(model: IChatModel, archived: boolean, reader: IReader): void; + } = Object.assign(Object.create(ChatWidget.prototype), { + _readOnly: false, + _draftOnly: draftOnly, + _visible: constObservable(false), + _readOnlyContextKey: { set: () => { } }, + policyRequiresAgentHost: constObservable(false), + agentSessionsService: { + model: { onDidChangeSessions: sessionChanges.event }, + getSession: () => ({ isArchived: () => archived }), + }, + chatSuggestNextWidget: { hide: () => { } }, + hasInputFocus: () => mainWindow.document.activeElement === input, + inputPartDisposable: { value: { element: inputContainer } }, + renderChatSuggestNextWidget: () => { }, + listWidget: { + focusLastItem: () => -1, + focus: () => transcript.focus(), + updateRendererOptions: () => { }, + }, + }); + const sessionArchived = widget.observeSessionArchived(model); + store.add(autorun(reader => widget.updateReadOnlyState(model, sessionArchived.read(reader), reader))); + input.value = 'Keep this draft'; + input.focus(); + input.setSelectionRange(2, 6); + const snapshot = () => ({ + visible: !inputContainer.classList.contains('chat-input-hidden'), + focused: mainWindow.document.activeElement === input, + draftOnly: draftOnly.get(), + text: input.value, + selection: [input.selectionStart, input.selectionEnd], + }); + const recorded = snapshot(); + archived = true; + sessionChanges.fire(); + const archivedWhileBlocked = snapshot(); + archived = false; + sessionChanges.fire(); + input.focus(); + const unarchived = snapshot(); + readOnly.set(false, undefined); + blocked.set(false, undefined); + const live = snapshot(); + readOnly.set(true, undefined); + + const draft = { visible: true, focused: true, draftOnly: true, text: 'Keep this draft', selection: [2, 6] }; + const restricted = { ...draft, visible: false, focused: false, draftOnly: false }; + assert.deepStrictEqual({ recorded, archivedWhileBlocked, unarchived, live, restricted: snapshot() }, { + recorded: draft, + archivedWhileBlocked: restricted, + unarchived: draft, + live: { ...draft, draftOnly: false }, + restricted, + }); + }); + + test('offline subagent composers inherit parent archive state without losing child restrictions', () => { + const parentResource = URI.parse('remote-agent-host-test:/session?owner=one'); + const childResource = getSubagentEditorResource({ + parentSessionResource: parentResource.toString(), chatResource: 'custom-chat:/opaque-worker', + }); + assert.ok(childResource); + let parentArchived = true; + let childArchived = true; + const sessions = new ResourceMap<{ isArchived(): boolean }>(); + sessions.set(parentResource, { isArchived: () => parentArchived }); + const sessionChanges = store.add(new Emitter()); + const model = upcastPartial({ + sessionResource: childResource, isReadOnly: constObservable(true), isInputBlocked: constObservable(true), + }); + let inputState = { readOnly: false, keepInputVisible: false }; + const widget: { + observeSessionArchived(model: IChatModel): IObservable; + updateReadOnlyState(model: IChatModel, archived: boolean, reader: IReader): void; + } = Object.assign(Object.create(ChatWidget.prototype), { + policyRequiresAgentHost: constObservable(false), + agentSessionsService: { + model: { onDidChangeSessions: sessionChanges.event }, + getSession: (resource: URI) => sessions.get(resource), + }, + setReadOnly: (readOnly: boolean, keepInputVisible: boolean) => inputState = { readOnly, keepInputVisible }, + }); + const archived = widget.observeSessionArchived(model); + store.add(autorun(reader => widget.updateReadOnlyState(model, archived.read(reader), reader))); + const states = [inputState]; + parentArchived = false; + sessionChanges.fire(); + states.push(inputState); + sessions.set(childResource, { isArchived: () => childArchived }); + sessionChanges.fire(); + states.push(inputState); + parentArchived = true; + childArchived = false; + sessionChanges.fire(); + states.push(inputState); + + assert.deepStrictEqual(states, [ + { readOnly: true, keepInputVisible: false }, + { readOnly: true, keepInputVisible: true }, + { readOnly: true, keepInputVisible: false }, + { readOnly: true, keepInputVisible: false }, + ]); + }); + + test('history recovery remains visible beside a blocked composer', async () => { + const { widget, layoutHeights } = createRerenderWidget(); + const inputElement = dom.$('div'); + let bannerVisible = false; + let message: string | undefined; + let action: { label: string; run: () => Promise } | undefined; + let retries = 0; + Object.assign(widget, { + setInputVisible: ChatWidget.prototype.setInputVisible, + inputPartDisposable: { value: { height: constObservable(10), element: inputElement } }, + readOnlyBanner: { + get visible() { return bannerVisible; }, + setVisible: (visible: boolean) => bannerVisible = visible, + setMessage: (value: string | undefined) => message = value, + setAction: (value: typeof action) => action = value, + }, + }); + widget.setReadOnly(true, true, { + kind: 'live', message: 'Live history unavailable', + action: { label: 'Retry', run: async () => { retries++; } }, + }); + const failed = { inputVisible: !inputElement.classList.contains('chat-input-hidden'), bannerVisible, message, action: action?.label }; + await action?.run(); + widget.setReadOnly(true, true, undefined); + widget.setReadOnly(true, true, undefined); + + assert.deepStrictEqual({ failed, inputVisible: !inputElement.classList.contains('chat-input-hidden'), bannerVisible, retries, layoutHeights }, { + failed: { inputVisible: true, bannerVisible: true, message: 'Live history unavailable', action: 'Retry' }, + inputVisible: true, bannerVisible: false, retries: 1, + layoutHeights: [90 - CHAT_READ_ONLY_BANNER_HEIGHT, 90], + }); + }); + test('applies read-only to rendered rows on the first layout after the list is attached', () => { const { widget, domNode, rerenders } = createRerenderWidget(); domNode.style.height = '50px'; @@ -1494,7 +1654,7 @@ suite('ChatWidget', () => { _readOnlyContextKey: { set: () => { } }, policyRequiresAgentHost: policy, readOnlyBanner: banner, agentSessionsService: { model: { onDidChangeSessions: sessionChanges.event }, - getSession: () => ({ isArchived: () => archivedState }), + getSession: () => ({ isArchived: () => selected.get() === local && archivedState }), }, chatSuggestNextWidget: { hide: () => { } }, hasInputFocus: () => false, setInputVisible: () => { }, renderChatSuggestNextWidget: () => { },