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
2 changes: 1 addition & 1 deletion src/vs/base/common/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export namespace Event {
export function debounce<T>(event: Event<T>, merge: (last: T | undefined, event: T) => T, delay?: number | typeof MicrotaskDelay, leading?: boolean, flushOnListenerRemove?: boolean, leakWarningThreshold?: number, disposable?: DisposableStore): Event<T>;
export function debounce<I, O>(event: Event<I>, merge: (last: O | undefined, event: I) => O, delay?: number | typeof MicrotaskDelay, leading?: boolean, flushOnListenerRemove?: boolean, leakWarningThreshold?: number, disposable?: DisposableStore): Event<O>;
export function debounce<I, O>(event: Event<I>, merge: (last: O | undefined, event: I) => O, delay: number | typeof MicrotaskDelay = 100, leading = false, flushOnListenerRemove = false, leakWarningThreshold?: number, disposable?: DisposableStore): Event<O> {
let subscription: IDisposable;
let subscription: IDisposable = Disposable.None;
let output: O | undefined = undefined;
let handle: Timeout | undefined | null = undefined;
let numDebouncedCalls = 0;
Expand Down
8 changes: 8 additions & 0 deletions src/vs/base/test/common/event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ suite('Event utils dispose', function () {

assertDisposablesCount([leaked]); // leaked is still there
});

test('debounce-util can be disposed without listeners', function () {
const store = new DisposableStore();
const emitter = ds.add(new Emitter<number>());
Event.debounce(emitter.event, l => 0, undefined, undefined, undefined, undefined, store);

store.dispose();
});
});

suite('Event', function () {
Expand Down
3 changes: 2 additions & 1 deletion src/vs/sessions/common/contextkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ export const SessionWorkspacePickerGroupContext = new RawContextKey<string>('ses
//#region < --- New Session Pickers --- >

export const SessionWorkspacePickerVisibleContext = new RawContextKey<boolean>('sessionWorkspacePickerVisible', false, localize('sessionWorkspacePickerVisible', "Whether the new-session view's workspace picker is rendered (as opposed to being replaced by the no-agent-host empty state)"));
export const SessionHarnessPickerVisibleContext = new RawContextKey<boolean>('sessionHarnessPickerVisible', false, localize('sessionHarnessPickerVisible', "Whether the new-session view's harness (session type) picker is visible — it is hidden when at most one harness can serve the selected workspace"));
export const SessionHarnessPickerVisibleContext = new RawContextKey<boolean>('sessionHarnessPickerVisible', false, localize('sessionHarnessPickerVisible', "Whether the new-session view's harness (session type) picker is rendered"));
export const SessionHarnessPickerInteractiveContext = new RawContextKey<boolean>('sessionHarnessPickerInteractive', false, localize('sessionHarnessPickerInteractive', "Whether the new-session view's harness (session type) picker can be interacted with"));
export const SessionIsolationPickerVisibleContext = new RawContextKey<boolean>('sessionIsolationPickerVisible', false, localize('sessionIsolationPickerVisible', "Whether the new-session view's isolation picker is visible — it is shown only when the isolation option is enabled and the workspace has a git repository"));
export const AgentHostSessionTypesAvailableContext = new RawContextKey<boolean>('agentHostSessionTypesAvailable', false, localize('agentHostSessionTypesAvailable', "Whether at least one connected agent-host provider has advertised session types"));

Expand Down
3 changes: 3 additions & 0 deletions src/vs/sessions/contrib/chat/browser/media/chatInput.css
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,9 @@
padding: 0 0 0 2px;
line-height: 100% !important;
align-self: center;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
}

.sessions-chat-attachment-pill .monaco-icon-label .monaco-icon-label-container {
Expand Down
4 changes: 4 additions & 0 deletions src/vs/sessions/contrib/chat/browser/media/chatWidget.css
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,10 @@
font-size: var(--vscode-codiconFontSize);
}

.sessions-chat-picker-slot.sessions-workspace-category-picker-slot .action-label > .sessions-chat-dropdown-chevron {
font-size: var(--vscode-codiconFontSize-compact);
}

.sessions-workspace-category-picker .sessions-chat-dropdown-label {
margin-left: 0;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { ILanguageService } from '../../../../editor/common/languages/language.j
import { getIconClasses } from '../../../../editor/common/services/getIconClasses.js';
import { basename } from '../../../../base/common/resources.js';
import { Schemas } from '../../../../base/common/network.js';
import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js';
import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../workbench/browser/labels.js';

import { IChatRequestVariableEntry, isAgentHostCompletionVariableEntry, isPastedTextArtifact, OmittedState } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js';
Expand Down Expand Up @@ -167,6 +168,13 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt
const icon = dom.append(content, renderIcon(Codicon.repo));
icon.setAttribute('aria-hidden', 'true');
dom.append(content, dom.$('span.sessions-chat-attachment-name', undefined, entry.name));
} else if (entry.icon) {
const icon = dom.append(content, renderIcon(entry.icon));
icon.setAttribute('aria-hidden', 'true');
if (entry.icon.color) {
icon.style.color = asCssVariable(entry.icon.color.id);
}
dom.append(content, dom.$('span.sessions-chat-attachment-name', undefined, entry.name));
} else {
const label = this._resourceLabels.create(content, { supportIcons: true });
this._renderDisposables.add(label);
Expand Down
15 changes: 13 additions & 2 deletions src/vs/sessions/contrib/chat/browser/newChatInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ import { IChatSubmitRequestHandlerService } from '../../../../workbench/contrib/
import { INewChatModelPickerService, NewChatModelPickerService } from './newChatModelPicker.js';
import { ModelPicker, ModelPickerActionViewItem } from './modelPicker.js';
import { ISessionModelSelection, SessionModelSelection } from './sessionModelSelection.js';
import { hasSendableModelSelection } from './sessionModelPickerState.js';
import { ISessionContext, SessionContext } from '../../../services/sessions/browser/sessionContext.js';
import { AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING } from './sessionsChatHistory.js';
import { IChatStatusItemService } from '../../../../workbench/contrib/chat/browser/chatStatus/chatStatusItemService.js';
Expand Down Expand Up @@ -510,7 +511,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation
return true;
}
const modelSelection = this._modelSelection.state.read(reader);
return this.options.canSendRequest.read(reader) && modelSelection.hasSelectableModel && !modelSelection.pendingSelection;
return this.options.canSendRequest.read(reader) && hasSendableModelSelection(modelSelection);
});
this._scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection(
[INewChatModelPickerService, this._newChatModelPickerService],
Expand All @@ -526,6 +527,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation
}
}));
}
this._register(this.storageService.onWillSaveState(() => this.saveState()));
this._contextAttachments = this._register(this.instantiationService.createInstance(NewChatContextAttachments));
// Always use the mobile-aware picker. Its overrides bail to the
// desktop behavior when `isPhoneLayout()` is false, so picking
Expand All @@ -534,7 +536,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation
// phone breakpoint after the chat input mounted.
this.sessionTypePicker = this._register(this.instantiationService.createInstance(MobileSessionTypePicker, this.options.session, this.options.sessionTypePickerOptions));
this._register(this._contextAttachments.onDidChangeContext(() => {
this._updateDraftState();
this._updateAndSaveDraftState();
this._updateSendButtonState();
this.focus();
}));
Expand Down Expand Up @@ -1375,6 +1377,14 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation
};
}

private _updateAndSaveDraftState(): void {
if (this._sending) {
return;
}
this._updateDraftState();
this.saveState();
}

private _toHistoryEntry(draft: IDraftState): IChatModelInputState {
return {
...draft,
Expand Down Expand Up @@ -1513,6 +1523,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation
this._contextAttachments.setAttachments(draft.attachments.map(IChatRequestVariableEntry.fromExport));
}
}
this._updateSendButtonState();
}

private _getDraftState(): IDraftState | undefined {
Expand Down
2 changes: 0 additions & 2 deletions src/vs/sessions/contrib/chat/browser/newChatWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,6 @@ export class NewChatWidget extends Disposable {
loading,
historyKey: constObservable(undefined), // no persisted history for the new-session view
placeholder: localize('newSessionPromptPlaceholder', "Pitch your idea"),
sessionTypePickerOptions: { showChevron: false },
supportsBackground: true,
deferredNotificationsEnabled,
petHostPreferred: this.options.petHostPreferred,
Expand Down Expand Up @@ -707,7 +706,6 @@ export class NewChatWidget extends Disposable {
label: localize('newSessionWorkspacePicker.githubContext', "Issue/PR"),
ariaLabel: localize('newSessionWorkspacePicker.githubContextAriaLabel', "Attach a GitHub issue or pull request to the new session"),
tooltip: localize('newSessionWorkspacePicker.githubContextTooltip', "Attach an issue or pull request as context"),
icon: Codicon.add,
hideIconWhenAttached: true,
group: SESSION_WORKSPACE_GROUP_GITHUB,
attachesContext: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ export function hasSelectableModel(
return models.length > 0 || options.showAutoModel;
}

export function hasSendableModelSelection(state: ISessionModelSelectionState): boolean {
return state.hasSelectableModel && (!state.pendingSelection || state.options.showAutoModel);
}

export const EMPTY_MODEL_SELECTION_STATE: ISessionModelSelectionState = {
currentModel: undefined,
pendingSelection: undefined,
Expand Down
32 changes: 17 additions & 15 deletions src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import { ISessionsProvidersService } from '../../../services/sessions/browser/se
import { autorun, IObservable, observableValue } from '../../../../base/common/observable.js';
import { ISession, SessionStatus } from '../../../services/sessions/common/session.js';
import { Emitter } from '../../../../base/common/event.js';
import { isWeb } from '../../../../base/common/platform.js';
import { isEqual } from '../../../../base/common/resources.js';
import { URI } from '../../../../base/common/uri.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
Expand All @@ -31,7 +30,7 @@ import { IChatInputNotificationService } from '../../../../workbench/contrib/cha
import { IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js';
import { markOnboardingTarget } from '../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js';
import { reportNewChatPickerClosed } from './newChatPickerTelemetry.js';
import { SessionHarnessPickerVisibleContext } from '../../../common/contextkeys.js';
import { SessionHarnessPickerInteractiveContext, SessionHarnessPickerVisibleContext } from '../../../common/contextkeys.js';
import { isAllowSignedOutWhenUsableEnabled } from '../../../browser/sessionsAuthGate.js';

const STORAGE_KEY_LAST_SESSION_TYPE = 'sessions.userSelectedSessionType';
Expand Down Expand Up @@ -149,13 +148,8 @@ export class SessionTypePicker extends Disposable {
private readonly _renderDisposables = this._register(new DisposableStore());
protected _triggerElement: HTMLElement | undefined;

/**
* Tracks whether the harness picker trigger is currently visible. Mirrors
* the `.hidden` state computed in {@link _updateTriggerLabel}, so the
* new-session-view onboarding tour can skip the harness step when only a
* single harness can serve the selected workspace.
*/
private readonly _visibleKey: IContextKey<boolean>;
private readonly _interactiveKey: IContextKey<boolean>;

constructor(
private readonly _session: IObservable<ISession | undefined>,
Expand All @@ -176,6 +170,8 @@ export class SessionTypePicker extends Disposable {

this._visibleKey = SessionHarnessPickerVisibleContext.bindTo(contextKeyService);
this._register(toDisposable(() => this._visibleKey.reset()));
this._interactiveKey = SessionHarnessPickerInteractiveContext.bindTo(contextKeyService);
this._register(toDisposable(() => this._interactiveKey.reset()));

// Restore the previously selected session type from storage
this._picked = this._readStoredPick();
Expand Down Expand Up @@ -632,23 +628,27 @@ export class SessionTypePicker extends Disposable {
private _updateTriggerLabel(): void {
if (!this._triggerElement) {
this._visibleKey.set(false);
this._interactiveKey.set(false);
return;
}

dom.clearNode(this._triggerElement);

// In web (vscode.dev/agents) the host filter already scopes the
// workbench to a single agent host, so when that host advertises only
// one harness there is nothing to pick — hide the trigger entirely.
const hideForSingleHarness = isWeb && this._folderSessionTypes.length <= 1 && this._pickServedByFolder(this._picked);
if (this._folderSessionTypes.length === 0 || hideForSingleHarness) {
if (this._folderSessionTypes.length === 0) {
this._triggerElement.classList.add('hidden');
this._triggerElement.parentElement?.classList.remove('disabled');
this._visibleKey.set(false);
this._interactiveKey.set(false);
return;
}

const disabled = this._folderSessionTypes.length === 1 && this._pickServedByFolder(this._picked);
this._triggerElement.classList.remove('hidden');
this._triggerElement.parentElement?.classList.toggle('disabled', disabled);
this._triggerElement.tabIndex = disabled ? -1 : 0;
this._triggerElement.setAttribute('aria-disabled', String(disabled));
this._visibleKey.set(true);
this._interactiveKey.set(!disabled);
const currentType = this._folderSessionTypes.find(t =>
t.providerId === this._picked?.providerId && t.sessionType.id === this._picked?.sessionTypeId)?.sessionType
?? this._folderSessionTypes.find(t => t.sessionType.id === this._picked?.sessionTypeId)?.sessionType;
Expand All @@ -659,11 +659,13 @@ export class SessionTypePicker extends Disposable {
const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label'));
labelSpan.textContent = modeLabel;

if (this._options?.showChevron !== false) {
if (!disabled && this._options?.showChevron !== false) {
const chevron = dom.append(this._triggerElement, renderIcon(Codicon.chevronDownCompact));
chevron.classList.add('sessions-chat-dropdown-chevron');
}

this._triggerElement.ariaLabel = localize('sessionTypePicker.triggerAriaLabel', "Pick Session Type, {0}", modeLabel);
this._triggerElement.ariaLabel = disabled
? localize('sessionTypePicker.disabledTriggerAriaLabel', "Session Type, {0}", modeLabel)
: localize('sessionTypePicker.triggerAriaLabel', "Pick Session Type, {0}", modeLabel);
}
}
11 changes: 9 additions & 2 deletions src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export interface IWorkspacePickerTrigger {
readonly label?: string;
readonly ariaLabel: string;
readonly tooltip?: string;
readonly icon: ThemeIcon;
readonly icon?: ThemeIcon;
readonly hideIconWhenAttached?: boolean;
readonly reflectsWorkspace?: boolean;
readonly group?: string;
Expand Down Expand Up @@ -136,6 +136,7 @@ interface IWorkspacePickerTriggerElements {
icon?: HTMLElement;
label?: HTMLElement;
badge?: CountBadge;
chevron?: HTMLElement;
}

type IWorkspacePickerAction = IAction & { icon?: ThemeIcon; hoverContent?: string; onRemove?: () => void };
Expand Down Expand Up @@ -1444,7 +1445,7 @@ export class WorkspacePicker extends Disposable {
trigger.classList.toggle('selected', (reflectsWorkspace && workspace !== undefined) || isSelectedCategory || badgeCount > 0 || relatedGitHubInfo !== undefined);
const icon = (reflectsWorkspace ? workspace?.icon : undefined)
?? (relatedGitHubInfo ? Codicon.repo : (isSelectedCategory && workspace ? workspace.icon : options.icon));
if (options.hideIconWhenAttached === true && badgeCount > 0) {
if (!icon || (options.hideIconWhenAttached === true && badgeCount > 0)) {
contents.icon?.remove();
contents.icon = undefined;
} else {
Expand Down Expand Up @@ -1475,6 +1476,12 @@ export class WorkspacePicker extends Disposable {
contents.badge?.dispose();
contents.badge = undefined;
}
if (!contents.chevron) {
contents.chevron = renderIcon(Codicon.chevronDownCompact);
contents.chevron.classList.add('sessions-chat-dropdown-chevron');
contents.chevron.setAttribute('aria-hidden', 'true');
}
trigger.append(contents.chevron);
return;
}

Expand Down
22 changes: 21 additions & 1 deletion src/vs/sessions/contrib/chat/test/browser/modelPicker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import assert from 'assert';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { ILanguageModelChatMetadataAndIdentifier } from '../../../../../workbench/contrib/chat/common/languageModels.js';
import { hasSelectableModel, normalizeModelPickerOptions } from '../../browser/sessionModelPickerState.js';
import { createModelSelectionState, hasSelectableModel, hasSendableModelSelection, normalizeModelPickerOptions } from '../../browser/sessionModelPickerState.js';

const aModel = { identifier: 'copilot-gpt-4o', metadata: {} } as ILanguageModelChatMetadataAndIdentifier;

Expand Down Expand Up @@ -42,4 +42,24 @@ suite('ModelPicker selectability', () => {
showManageModelsAction: false,
})), true);
});

test('allows an unresolved selection only when Auto is available', () => {
const pendingSelection = { reference: 'pending-model' };
const options = {
useGroupedModelPicker: true,
showFeatured: true,
showUnavailableFeatured: false,
showManageModelsAction: false,
};
const autoOptions = normalizeModelPickerOptions({ ...options, showAutoModel: true });
const explicitModelOptions = normalizeModelPickerOptions({ ...options, showAutoModel: false });

assert.deepStrictEqual({
auto: hasSendableModelSelection(createModelSelectionState([], autoOptions, undefined, pendingSelection)),
explicitModel: hasSendableModelSelection(createModelSelectionState([], explicitModelOptions, undefined, pendingSelection)),
}, {
auto: true,
explicitModel: false,
});
});
});
Loading
Loading