Skip to content

Commit b3f7c8c

Browse files
Add a dedicated Automation session draft (#329278)
* Add a dedicated Automation session draft Keep the Automation dialog draft isolated from New Chat and synchronize it with workspace, provider, session type, and workspace-less target changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 91cf0d5 commit b3f7c8c

8 files changed

Lines changed: 434 additions & 7 deletions

File tree

src/vs/sessions/SESSIONS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Defines the foundational interfaces that all providers and consumers share:
4141
- **`ISession`** (`session.ts`) — Universal session facade. A self-contained observable object representing a session; consumers never reach back to provider internals. Each session has a globally unique ID built via `toSessionId(providerId, resource)` and groups one or more `IChat` instances.
4242
- **`ISessionsProvider`** (`sessionsProvider.ts`) — Contract every provider implements. Covers workspace discovery, session CRUD, sending requests, model enumeration/selection/presentation (`getModelsSnapshot`, `getModelPickerOptions`, `onDidChangeModels`, `setModel`), and firing change events.
4343
- **`ISessionsManagementService`** (`sessionsManagement.ts`) — The session **model** service. Aggregates sessions from all providers, owns the pending new-session draft (`createNewSession`/`newSession`), send (`sendNewChatRequest`/`createAndSendNewChatRequest`/`sendRequest`), CRUD (archive/delete/rename), and recency history. It performs **no** view/layout mutation and never imports the view or part service. It does **not** own the active session — that lives in the view service.
44+
The Automation dialog uses a separate `automationSession` draft lifecycle so changing or closing the dialog never replaces the regular new-session draft.
4445

4546
> **Model vs view.** The active session (`activeSession`), the visible-session slots and their arrangement, opening sessions, focus, Back/Forward navigation, and per-session view persistence live in **`ISessionsService`** (services — see `services/sessions/browser/sessionsService.ts`), not the management service. The split mirrors `IEditorService.activeEditor` (the active item is owned by the view-facing service) rather than the underlying model. See [Model vs View](#model-vs-view-session-services).
4647

src/vs/sessions/contrib/automations/browser/automationDialog.ts

Lines changed: 158 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ import { CancellationTokenSource } from '../../../../base/common/cancellation.js
1515
import { Codicon } from '../../../../base/common/codicons.js';
1616
import { Emitter, Event } from '../../../../base/common/event.js';
1717
import { KeyCode } from '../../../../base/common/keyCodes.js';
18-
import { DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
18+
import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
1919
import { autorun, constObservable, derived, IObservable } from '../../../../base/common/observable.js';
20+
import { isEqual } from '../../../../base/common/resources.js';
2021
import { URI } from '../../../../base/common/uri.js';
2122
import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';
2223
import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js';
@@ -193,9 +194,130 @@ interface IRenderFormHandle {
193194
readonly getPermissionLevel: () => string | undefined;
194195
readonly getModelId: () => string | undefined;
195196
readonly getBranch: () => string | undefined;
197+
readonly waitForAutomationSessionSync: () => Promise<void>;
196198
readonly getFocusableElements: () => readonly HTMLElement[];
197199
}
198200

201+
export type AutomationSessionDraftTarget =
202+
| { readonly kind: 'workspace'; readonly folderUri: URI; readonly providerId: string | undefined; readonly sessionTypeId: string }
203+
| { readonly kind: 'quickChat'; readonly providerId: string; readonly sessionTypeId: string };
204+
205+
type AutomationSessionDraftService = Pick<
206+
ISessionsManagementService,
207+
'automationSession' | 'createAutomationSession' | 'createAutomationQuickChat' | 'discardAutomationSession'
208+
>;
209+
210+
export class AutomationSessionDraftSynchronizer extends Disposable {
211+
private requestedTarget: AutomationSessionDraftTarget | undefined;
212+
private appliedTarget: AutomationSessionDraftTarget | undefined;
213+
private session: ISession | undefined;
214+
private generation = 0;
215+
private syncScheduled = false;
216+
private syncPromise = Promise.resolve();
217+
private disposed = false;
218+
219+
constructor(
220+
private readonly sessionsManagementService: AutomationSessionDraftService,
221+
private readonly canSelectWorkspace: (folderUri: URI, preferredProviderId: string | undefined) => Promise<boolean>,
222+
private readonly onError: (error: unknown) => void,
223+
) {
224+
super();
225+
}
226+
227+
update(target: AutomationSessionDraftTarget | undefined): void {
228+
this.requestedTarget = target;
229+
this.generation++;
230+
this.scheduleSync();
231+
}
232+
233+
async waitForSync(): Promise<void> {
234+
let pendingSync: Promise<void>;
235+
do {
236+
pendingSync = this.syncPromise;
237+
await pendingSync;
238+
} while (pendingSync !== this.syncPromise);
239+
}
240+
241+
private scheduleSync(): void {
242+
if (this.syncScheduled) {
243+
return;
244+
}
245+
this.syncScheduled = true;
246+
this.syncPromise = Promise.resolve().then(() => {
247+
this.syncScheduled = false;
248+
if (!this.disposed) {
249+
return this.sync(this.generation);
250+
}
251+
return undefined;
252+
});
253+
}
254+
255+
private async sync(generation: number): Promise<void> {
256+
const target = this.requestedTarget;
257+
if (!target) {
258+
this.discardSession();
259+
return;
260+
}
261+
if (this.matchesAppliedTarget(target)) {
262+
return;
263+
}
264+
try {
265+
if (target.kind === 'workspace' && !await this.canSelectWorkspace(target.folderUri, target.providerId)) {
266+
if (generation === this.generation) {
267+
this.discardSession();
268+
}
269+
return;
270+
}
271+
if (this.disposed || generation !== this.generation) {
272+
return;
273+
}
274+
this.session = target.kind === 'quickChat'
275+
? this.sessionsManagementService.createAutomationQuickChat({
276+
providerId: target.providerId,
277+
sessionTypeId: target.sessionTypeId,
278+
})
279+
: this.sessionsManagementService.createAutomationSession(target.folderUri, {
280+
providerId: target.providerId,
281+
sessionTypeId: target.sessionTypeId,
282+
});
283+
this.appliedTarget = target;
284+
} catch (error) {
285+
if (!this.disposed && generation === this.generation) {
286+
this.discardSession();
287+
this.onError(error);
288+
}
289+
}
290+
}
291+
292+
private matchesAppliedTarget(target: AutomationSessionDraftTarget): boolean {
293+
if (!this.session
294+
|| !this.appliedTarget
295+
|| this.sessionsManagementService.automationSession.get()?.sessionId !== this.session.sessionId
296+
|| this.appliedTarget.kind !== target.kind
297+
|| this.appliedTarget.providerId !== target.providerId
298+
|| this.appliedTarget.sessionTypeId !== target.sessionTypeId) {
299+
return false;
300+
}
301+
return target.kind === 'quickChat'
302+
|| (this.appliedTarget.kind === 'workspace' && isEqual(this.appliedTarget.folderUri, target.folderUri));
303+
}
304+
305+
private discardSession(): void {
306+
if (this.session) {
307+
this.sessionsManagementService.discardAutomationSession(this.session);
308+
}
309+
this.session = undefined;
310+
this.appliedTarget = undefined;
311+
}
312+
313+
override dispose(): void {
314+
this.disposed = true;
315+
this.generation++;
316+
this.discardSession();
317+
super.dispose();
318+
}
319+
}
320+
199321
export function resolveAutomationModelIdentifier(
200322
languageModelsService: Pick<ILanguageModelsService, 'getLanguageModelIds' | 'lookupLanguageModel'>,
201323
identifier: string,
@@ -806,10 +928,6 @@ export function renderForm(
806928
// Covers both explicit user picks and recomputes (e.g. an agent host
807929
// advertising its session types after the dialog opened), so the saved
808930
// automation always matches the chip the picker displays.
809-
disposables.add(sessionTypePicker.onDidChangeSelectedPick(() => {
810-
syncStateFromPicker();
811-
revalidate();
812-
}));
813931

814932
const workspacePicker = disposables.add(instantiationService.createInstance(MobileAutomationsWorkspacePicker, {
815933
canSelectWorkspace: (folderUri, preferredProviderId) =>
@@ -818,12 +936,42 @@ export function renderForm(
818936
workspacePicker.setTargetModel(isolationModel);
819937
workspacePicker.setLayoutService(layoutService);
820938

939+
const automationSessionDraftSynchronizer = disposables.add(new AutomationSessionDraftSynchronizer(
940+
sessionsManagementService,
941+
(folderUri, preferredProviderId) => canSelectAutomationWorkspace(folderUri, preferredProviderId, sessionsManagementService, workspaceTrustRequestService),
942+
error => logService.error('[AutomationDialog] Failed to synchronize the automation session draft.', error),
943+
));
944+
const updateAutomationSessionTarget = () => {
945+
const folderUri = isolationModel.folderUriObs.get();
946+
const pick = sessionTypePicker.selectedPick;
947+
const isQuickChat = isolationModel.isQuickChatObs.get();
948+
if (!pick || (isQuickChat && !pick.providerId) || (!isQuickChat && !folderUri)) {
949+
automationSessionDraftSynchronizer.update(undefined);
950+
return;
951+
}
952+
if (isQuickChat) {
953+
const providerId = pick.providerId;
954+
if (providerId) {
955+
automationSessionDraftSynchronizer.update({ kind: 'quickChat', providerId, sessionTypeId: pick.sessionTypeId });
956+
}
957+
} else if (folderUri) {
958+
automationSessionDraftSynchronizer.update({ kind: 'workspace', folderUri, providerId: pick.providerId, sessionTypeId: pick.sessionTypeId });
959+
}
960+
};
961+
disposables.add(sessionTypePicker.onDidChangeSelectedPick(() => {
962+
syncStateFromPicker();
963+
updateAutomationSessionTarget();
964+
revalidate();
965+
}));
966+
disposables.add(sessionsManagementService.onDidChangeSessionTypes(() => updateAutomationSessionTarget()));
967+
821968
if (state.folderUri) {
822969
workspacePicker.setSelectedWorkspace(state.folderUri, { fireEvent: false, persist: false });
823970
}
824971

825972
disposables.add(workspacePicker.onDidSelectWorkspace(uri => {
826973
if (isolationModel.setWorkspace(uri)) {
974+
updateAutomationSessionTarget();
827975
revalidate();
828976
}
829977
}));
@@ -834,6 +982,7 @@ export function renderForm(
834982

835983
disposables.add(autorun(reader => {
836984
isolationModel.isQuickChatObs.read(reader);
985+
updateAutomationSessionTarget();
837986
revalidate();
838987
}));
839988

@@ -1045,6 +1194,10 @@ export function renderForm(
10451194
getPermissionLevel: () => chatInput.currentPermissionLevelObs.get(),
10461195
getModelId: () => chatInput.selectedLanguageModel.get()?.identifier,
10471196
getBranch: () => isolationModel.persistedBranch,
1197+
waitForAutomationSessionSync: () => {
1198+
updateAutomationSessionTarget();
1199+
return automationSessionDraftSynchronizer.waitForSync();
1200+
},
10481201
getFocusableElements: () => {
10491202
// eslint-disable-next-line no-restricted-syntax -- the dialog owns this form subtree and supplies its dynamic focus order.
10501203
return Array.from(form.querySelectorAll<HTMLElement>('input, select, textarea, button, a[href], [tabindex]'));

src/vs/sessions/contrib/automations/browser/automationDialogService.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ export class AutomationDialogService implements IAutomationDialogService {
114114
let getPermissionLevel: () => string | undefined = () => initial?.permissionLevel;
115115
let getModelId: () => string | undefined = () => initial?.modelId;
116116
let getBranch: () => string | undefined = () => initialWorkspaceTarget?.isolation.kind === 'worktree' ? initialWorkspaceTarget.isolation.branch : undefined;
117+
let waitForAutomationSessionSync: () => Promise<void> = async () => { };
117118
let getFocusableElements: () => readonly HTMLElement[] = () => [];
118119
let focusFirst: () => void = () => { };
119120

@@ -171,6 +172,7 @@ export class AutomationDialogService implements IAutomationDialogService {
171172
getPermissionLevel = handle.getPermissionLevel;
172173
getModelId = handle.getModelId;
173174
getBranch = handle.getBranch;
175+
waitForAutomationSessionSync = handle.waitForAutomationSessionSync;
174176
getFocusableElements = handle.getFocusableElements;
175177
const keyboardNavigation = disposables.add(registerAutomationDialogKeyboardNavigation(
176178
DOM.getWindow(container),
@@ -206,6 +208,7 @@ export class AutomationDialogService implements IAutomationDialogService {
206208
if ((!state.isQuickChat && !state.folderUri) || !state.sessionTypeId || (state.isQuickChat && !state.providerId)) {
207209
return undefined;
208210
}
211+
await waitForAutomationSessionSync();
209212

210213
const schedule: IAutomationSchedule = {
211214
interval: state.interval,

0 commit comments

Comments
 (0)