mirror of
https://github.com/microsoft/vscode.git
synced 2026-09-06 05:38:13 +01:00
[cherry-pick] sessions: fix new session context attachments and pickers (#333791)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
@@ -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],
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}));
|
||||
@@ -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,
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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>,
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -136,6 +136,7 @@ interface IWorkspacePickerTriggerElements {
|
||||
icon?: HTMLElement;
|
||||
label?: HTMLElement;
|
||||
badge?: CountBadge;
|
||||
chevron?: HTMLElement;
|
||||
}
|
||||
|
||||
type IWorkspacePickerAction = IAction & { icon?: ThemeIcon; hoverContent?: string; onRemove?: () => void };
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import assert from 'assert';
|
||||
import { DeferredPromise } from '../../../../../base/common/async.js';
|
||||
import { Codicon } from '../../../../../base/common/codicons.js';
|
||||
import { DisposableStore, IDisposable, IReference } from '../../../../../base/common/lifecycle.js';
|
||||
import { Schemas } from '../../../../../base/common/network.js';
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
@@ -32,6 +33,10 @@ const holdInputModelReference = Reflect.get(NewChatInputWidget.prototype, '_hold
|
||||
const getDraftState = Reflect.get(NewChatInputWidget.prototype, '_getDraftState') as (this: IDraftStateHarness) => { inputText: string; attachments: readonly IChatRequestVariableEntry[] } | undefined;
|
||||
const restoreState = Reflect.get(NewChatInputWidget.prototype, '_restoreState') as (this: IRestoreStateHarness) => void;
|
||||
const saveState = Reflect.get(NewChatInputWidget.prototype, 'saveState') as (this: IDraftStateHarness) => void;
|
||||
const clearDraftState = Reflect.get(NewChatInputWidget.prototype, '_clearDraftState') as (this: IDraftStateHarness) => void;
|
||||
const updateDraftState = Reflect.get(NewChatInputWidget.prototype, '_updateDraftState') as (this: IUpdateDraftStateHarness) => void;
|
||||
const updateAndSaveDraftState = Reflect.get(NewChatInputWidget.prototype, '_updateAndSaveDraftState') as (this: IUpdateAndSaveDraftStateHarness) => void;
|
||||
const updateSendButtonState = Reflect.get(NewChatInputWidget.prototype, '_updateSendButtonState') as (this: IUpdateSendButtonStateHarness) => void;
|
||||
const updateAttachmentRendering = Reflect.get(NewChatContextAttachments.prototype, '_updateRendering') as (this: IAttachmentRenderingHarness) => void;
|
||||
|
||||
interface IDraftStateHarness {
|
||||
@@ -50,6 +55,37 @@ interface IRestoreStateHarness {
|
||||
readonly _contextAttachments: {
|
||||
setAttachments(entries: readonly IChatRequestVariableEntry[]): void;
|
||||
};
|
||||
_updateSendButtonState(): void;
|
||||
}
|
||||
|
||||
interface IUpdateDraftStateHarness extends IDraftStateHarness {
|
||||
readonly _editor: {
|
||||
getModel(): { getValue(): string } | null;
|
||||
};
|
||||
readonly _contextAttachments: {
|
||||
readonly attachments: readonly IChatRequestVariableEntry[];
|
||||
};
|
||||
}
|
||||
|
||||
interface IUpdateAndSaveDraftStateHarness extends IUpdateDraftStateHarness {
|
||||
readonly _sending: boolean;
|
||||
_updateDraftState(): void;
|
||||
saveState(): void;
|
||||
}
|
||||
|
||||
interface IUpdateSendButtonStateHarness {
|
||||
readonly _sendButton: { enabled: boolean } | undefined;
|
||||
readonly _sending: boolean;
|
||||
readonly _editor: {
|
||||
getModel(): { getValue(): string } | null;
|
||||
};
|
||||
readonly _contextAttachments: {
|
||||
readonly attachments: readonly IChatRequestVariableEntry[];
|
||||
};
|
||||
readonly options: {
|
||||
readonly hasAdditionalSendContent?: { get(): boolean };
|
||||
};
|
||||
readonly _canSendRequest: { get(): boolean };
|
||||
}
|
||||
|
||||
interface IAttachmentRenderingHarness {
|
||||
@@ -178,11 +214,19 @@ suite('NewChatInputWidget', () => {
|
||||
value: repositoryRoot,
|
||||
},
|
||||
];
|
||||
const saveHarness: IDraftStateHarness = {
|
||||
const saveHarness: IUpdateAndSaveDraftStateHarness = {
|
||||
storageService,
|
||||
_draftState: { inputText: 'Fix this', attachments },
|
||||
_sending: false,
|
||||
_editor: { getModel: () => ({ getValue: () => '' }) },
|
||||
_contextAttachments: { attachments },
|
||||
_updateDraftState() {
|
||||
updateDraftState.call(this);
|
||||
},
|
||||
saveState() {
|
||||
saveState.call(this);
|
||||
},
|
||||
};
|
||||
saveState.call(saveHarness);
|
||||
updateAndSaveDraftState.call(saveHarness);
|
||||
const restored: { inputText?: string; attachments?: readonly IChatRequestVariableEntry[] } = {};
|
||||
const draft = getDraftState.call({ storageService });
|
||||
|
||||
@@ -190,6 +234,7 @@ suite('NewChatInputWidget', () => {
|
||||
_getDraftState: () => draft,
|
||||
_editor: { getModel: () => ({ setValue: value => restored.inputText = value }) },
|
||||
_contextAttachments: { setAttachments: entries => restored.attachments = entries },
|
||||
_updateSendButtonState: () => { },
|
||||
});
|
||||
|
||||
assert.deepStrictEqual({
|
||||
@@ -198,13 +243,100 @@ suite('NewChatInputWidget', () => {
|
||||
folderValue: restored.attachments?.[0].value,
|
||||
repositoryValue: restored.attachments?.[1].value,
|
||||
}, {
|
||||
inputText: 'Fix this',
|
||||
inputText: '',
|
||||
attachmentIds: attachments.map(attachment => attachment.id),
|
||||
folderValue: folder,
|
||||
repositoryValue: repositoryRoot,
|
||||
});
|
||||
});
|
||||
|
||||
test('persists draft text when state is saved', () => {
|
||||
let stored: string | undefined;
|
||||
const storageService: IDraftStateHarness['storageService'] = {
|
||||
get: () => stored,
|
||||
store: (_key, value) => stored = value,
|
||||
};
|
||||
const harness: IUpdateAndSaveDraftStateHarness = {
|
||||
storageService,
|
||||
_sending: false,
|
||||
_editor: { getModel: () => ({ getValue: () => 'Fix this after reload' }) },
|
||||
_contextAttachments: { attachments: [] },
|
||||
_updateDraftState() {
|
||||
updateDraftState.call(this);
|
||||
},
|
||||
saveState() {
|
||||
saveState.call(this);
|
||||
},
|
||||
};
|
||||
|
||||
updateDraftState.call(harness);
|
||||
saveState.call(harness);
|
||||
|
||||
assert.deepStrictEqual(getDraftState.call({ storageService }), {
|
||||
inputText: 'Fix this after reload',
|
||||
attachments: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('does not re-persist a sent prompt when attachments clear during send', () => {
|
||||
let stored: string | undefined;
|
||||
let editorValue = 'Fix this';
|
||||
const storageService: IDraftStateHarness['storageService'] = {
|
||||
get: () => stored,
|
||||
store: (_key, value) => stored = value,
|
||||
};
|
||||
const harness: IUpdateAndSaveDraftStateHarness = {
|
||||
storageService,
|
||||
_sending: true,
|
||||
_editor: { getModel: () => ({ getValue: () => editorValue }) },
|
||||
_contextAttachments: { attachments: [] },
|
||||
_updateDraftState() {
|
||||
updateDraftState.call(this);
|
||||
},
|
||||
saveState() {
|
||||
saveState.call(this);
|
||||
},
|
||||
};
|
||||
|
||||
clearDraftState.call(harness);
|
||||
updateAndSaveDraftState.call(harness);
|
||||
editorValue = '';
|
||||
updateDraftState.call(harness);
|
||||
|
||||
assert.deepStrictEqual(getDraftState.call({ storageService }), {
|
||||
inputText: '',
|
||||
attachments: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('enables send after restoring an unchanged retained input model', () => {
|
||||
const sendButton = { enabled: false };
|
||||
const harness: IRestoreStateHarness & IUpdateSendButtonStateHarness = {
|
||||
_getDraftState: () => ({ inputText: 'Fix this', attachments: [] }),
|
||||
_sendButton: sendButton,
|
||||
_sending: false,
|
||||
_editor: {
|
||||
getModel: () => ({
|
||||
getValue: () => 'Fix this',
|
||||
setValue: () => { },
|
||||
}),
|
||||
},
|
||||
_contextAttachments: {
|
||||
attachments: [],
|
||||
setAttachments: () => { },
|
||||
},
|
||||
options: {},
|
||||
_canSendRequest: { get: () => true },
|
||||
_updateSendButtonState() {
|
||||
updateSendButtonState.call(this);
|
||||
},
|
||||
};
|
||||
|
||||
restoreState.call(harness);
|
||||
|
||||
assert.strictEqual(sendButton.enabled, true);
|
||||
});
|
||||
|
||||
test('renders GitHub context pills as openable with a keyboard-reachable remove button', async () => {
|
||||
const container = document.createElement('div');
|
||||
const entry = toPasteVariableEntry('microsoft/vscode#332825', 'GitHub context: https://github.com/microsoft/vscode/pull/332825', {
|
||||
@@ -268,6 +400,66 @@ suite('NewChatInputWidget', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('renders issue and pull request attachment icons without nested focus targets', () => {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const entries = [
|
||||
toPasteVariableEntry('microsoft/vscode#9014', 'Issue context', {
|
||||
id: 'github-context:https://github.com/microsoft/vscode/issues/9014',
|
||||
icon: { ...Codicon.issues, color: { id: 'charts.green' } },
|
||||
}),
|
||||
toPasteVariableEntry('microsoft/vscode#123', 'Pull request context', {
|
||||
id: 'github-context:https://github.com/microsoft/vscode/pull/123',
|
||||
icon: Codicon.gitPullRequest,
|
||||
}),
|
||||
];
|
||||
const renderDisposables = disposables.add(new DisposableStore());
|
||||
try {
|
||||
updateAttachmentRendering.call({
|
||||
_container: container,
|
||||
_attachedContext: entries,
|
||||
_renderDisposables: renderDisposables,
|
||||
_resourceLabels: {
|
||||
clear: () => { },
|
||||
create: () => ({
|
||||
dispose: () => { },
|
||||
setLabel: () => { },
|
||||
setFile: () => { },
|
||||
}),
|
||||
},
|
||||
openerService: { open: async () => true },
|
||||
removeAttachment: () => { },
|
||||
});
|
||||
|
||||
const pills = Array.from(container.querySelectorAll('.sessions-chat-attachment-pill'));
|
||||
const focusTargets = pills.map(pill => pill.querySelector<HTMLButtonElement>('.sessions-chat-attachment-open'));
|
||||
focusTargets[0]?.focus();
|
||||
const issueButtonFocused = document.activeElement === focusTargets[0];
|
||||
focusTargets[1]?.focus();
|
||||
const pullRequestButtonFocused = document.activeElement === focusTargets[1];
|
||||
|
||||
assert.deepStrictEqual({
|
||||
pills: pills.map(pill => ({
|
||||
label: pill.querySelector('.sessions-chat-attachment-name')?.textContent,
|
||||
icon: pill.querySelector('.codicon:not(.codicon-close-compact)')?.className,
|
||||
color: pill.querySelector<HTMLElement>('.codicon:not(.codicon-close-compact)')?.style.color,
|
||||
nestedLinks: pill.querySelectorAll('a').length,
|
||||
})),
|
||||
issueButtonFocused,
|
||||
pullRequestButtonFocused,
|
||||
}, {
|
||||
pills: [
|
||||
{ label: 'microsoft/vscode#9014', icon: 'codicon codicon-issues', color: 'var(--vscode-charts-green)', nestedLinks: 0 },
|
||||
{ label: 'microsoft/vscode#123', icon: 'codicon codicon-git-pull-request', color: '', nestedLinks: 0 },
|
||||
],
|
||||
issueButtonFocused: true,
|
||||
pullRequestButtonFocused: true,
|
||||
});
|
||||
} finally {
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
test('renders additional folder and repository context as attachment pills', () => {
|
||||
const container = document.createElement('div');
|
||||
const folder = URI.file('/workspace/docs');
|
||||
|
||||
@@ -327,7 +327,7 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/newWidget/' }, {
|
||||
}),
|
||||
NewSessionAttachedContext: defineComponentFixture({
|
||||
labels: { kind: 'screenshot', blocksCi: true },
|
||||
expectedVisualDescriptions: ['The new-session workspace row shows Copilot, microsoft/vscode with a count badge showing 2, and Issue/PR with a count badge showing 1. The composer attachment row shows removable docs, microsoft/typescript, and microsoft/vscode#333053 context pills with compact dismiss icons.'],
|
||||
expectedVisualDescriptions: ['The new-session workspace row shows Copilot, microsoft/vscode with a count badge showing 2, and Issue/PR with a count badge showing 1. The composer attachment row shows removable docs, microsoft/typescript, and microsoft/vscode#333053 context pills with compact dismiss icons. The folder icon is fully visible without cropping, and the GitHub issue pill includes an issue icon.'],
|
||||
render: context => renderNewChatWidget(context, { withWorkspace: true, withAttachedContext: true }),
|
||||
}),
|
||||
NewSessionRemoteWorkspace: defineComponentFixture({
|
||||
|
||||
@@ -117,7 +117,7 @@ interface ISendHarness {
|
||||
interface IRenderWorkspacePickerHarness {
|
||||
readonly _workspacePickerVisibleKey: { set(value: boolean): void };
|
||||
readonly _workspacePicker: {
|
||||
renderCategoryTriggers(container: HTMLElement, triggers: readonly { readonly label?: string; readonly tooltip?: string; readonly attachesContext?: boolean }[]): HTMLElement;
|
||||
renderCategoryTriggers(container: HTMLElement, triggers: readonly { readonly label?: string; readonly tooltip?: string; readonly icon?: { readonly id: string }; readonly attachesContext?: boolean }[]): HTMLElement;
|
||||
};
|
||||
readonly _newChatInput: {
|
||||
readonly sessionTypePicker: {
|
||||
@@ -161,7 +161,7 @@ suite('NewChatWidget', () => {
|
||||
test('workspace row hosts a multiple-harness picker first', () => {
|
||||
const container = document.createElement('div');
|
||||
const harnessLabels = ['Copilot', 'Claude'];
|
||||
const workspaceTriggers: { readonly tooltip: string | undefined; readonly attachesContext: boolean | undefined }[] = [];
|
||||
const workspaceTriggers: { readonly tooltip: string | undefined; readonly icon: string | undefined; readonly attachesContext: boolean | undefined }[] = [];
|
||||
const harness: IRenderWorkspacePickerHarness = {
|
||||
_workspacePickerVisibleKey: { set: () => { } },
|
||||
_workspacePicker: {
|
||||
@@ -172,7 +172,7 @@ suite('NewChatWidget', () => {
|
||||
const item = document.createElement('div');
|
||||
item.textContent = trigger.label ?? 'More';
|
||||
row.appendChild(item);
|
||||
workspaceTriggers.push({ tooltip: trigger.tooltip, attachesContext: trigger.attachesContext });
|
||||
workspaceTriggers.push({ tooltip: trigger.tooltip, icon: trigger.icon?.id, attachesContext: trigger.attachesContext });
|
||||
}
|
||||
return row;
|
||||
},
|
||||
@@ -207,8 +207,8 @@ suite('NewChatWidget', () => {
|
||||
],
|
||||
);
|
||||
assert.deepStrictEqual(workspaceTriggers, [
|
||||
{ tooltip: 'Choose where the new session runs', attachesContext: false },
|
||||
{ tooltip: 'Attach an issue or pull request as context', attachesContext: true },
|
||||
{ tooltip: 'Choose where the new session runs', icon: 'project', attachesContext: false },
|
||||
{ tooltip: 'Attach an issue or pull request as context', icon: undefined, attachesContext: true },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { IChatSessionsService } from '../../../../../workbench/contrib/chat/comm
|
||||
import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js';
|
||||
import { ChatEntitlement, IChatEntitlementService } from '../../../../../workbench/services/chat/common/chatEntitlementService.js';
|
||||
import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js';
|
||||
import { SessionHarnessPickerInteractiveContext, SessionHarnessPickerVisibleContext } from '../../../../common/contextkeys.js';
|
||||
import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';
|
||||
import { IProviderSessionType, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';
|
||||
import { SessionTypeAuthRequirement, ISession, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js';
|
||||
@@ -116,6 +117,7 @@ function createPicker(
|
||||
storage: IStorageService,
|
||||
options?: ISessionTypePickerOptions,
|
||||
actionWidgetService: Partial<IActionWidgetService> = { isVisible: false, hide: () => { }, show: () => { } },
|
||||
contextKeyService: IContextKeyService = new MockContextKeyService(),
|
||||
): TestSessionTypePicker {
|
||||
const instantiationService = disposables.add(new TestInstantiationService());
|
||||
instantiationService.stub(IActionWidgetService, actionWidgetService);
|
||||
@@ -135,7 +137,7 @@ function createPicker(
|
||||
});
|
||||
instantiationService.stub(IConfigurationService, new TestConfigurationService());
|
||||
instantiationService.stub(IChatInputNotificationService, { getActiveNotification: () => undefined });
|
||||
instantiationService.stub(IContextKeyService, new MockContextKeyService());
|
||||
instantiationService.stub(IContextKeyService, contextKeyService);
|
||||
return disposables.add(instantiationService.createInstance(TestSessionTypePicker, session, options));
|
||||
}
|
||||
|
||||
@@ -264,6 +266,60 @@ suite('SessionTypePicker', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('disables the trigger when the selected workspace has only one session type', () => {
|
||||
management.setSessionTypes([
|
||||
sessionType('copilot', 'cloud', 'Cloud'),
|
||||
]);
|
||||
const contextKeyService = new MockContextKeyService();
|
||||
const picker = createPicker(disposables, session, management, storage, undefined, undefined, contextKeyService);
|
||||
session.set(createFakeSession('copilot', 'cloud', folder), undefined);
|
||||
const container = document.createElement('div');
|
||||
picker.render(container);
|
||||
const trigger = container.querySelector<HTMLElement>('.action-label');
|
||||
const singleType = {
|
||||
hidden: trigger?.classList.contains('hidden'),
|
||||
disabled: trigger?.getAttribute('aria-disabled'),
|
||||
tabIndex: trigger?.tabIndex,
|
||||
label: trigger?.getAttribute('aria-label'),
|
||||
visible: contextKeyService.getContextKeyValue(SessionHarnessPickerVisibleContext.key),
|
||||
interactive: contextKeyService.getContextKeyValue(SessionHarnessPickerInteractiveContext.key),
|
||||
};
|
||||
|
||||
management.setSessionTypes([
|
||||
sessionType('copilot', 'cloud', 'Cloud'),
|
||||
sessionType('local-agent-host', 'local', 'Local'),
|
||||
]);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
singleType,
|
||||
multipleTypes: {
|
||||
hidden: trigger?.classList.contains('hidden'),
|
||||
disabled: trigger?.getAttribute('aria-disabled'),
|
||||
tabIndex: trigger?.tabIndex,
|
||||
label: trigger?.getAttribute('aria-label'),
|
||||
visible: contextKeyService.getContextKeyValue(SessionHarnessPickerVisibleContext.key),
|
||||
interactive: contextKeyService.getContextKeyValue(SessionHarnessPickerInteractiveContext.key),
|
||||
},
|
||||
}, {
|
||||
singleType: {
|
||||
hidden: false,
|
||||
disabled: 'true',
|
||||
tabIndex: -1,
|
||||
label: 'Session Type, Cloud',
|
||||
visible: true,
|
||||
interactive: false,
|
||||
},
|
||||
multipleTypes: {
|
||||
hidden: false,
|
||||
disabled: 'false',
|
||||
tabIndex: 0,
|
||||
label: 'Pick Session Type, Cloud',
|
||||
visible: true,
|
||||
interactive: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('re-selecting the default (first) session type clears the stored pick', () => {
|
||||
management.setSessionTypes([
|
||||
sessionType('local-1', 'local', 'Local'),
|
||||
|
||||
@@ -1225,14 +1225,15 @@ suite('WorkspacePicker - Category Triggers', () => {
|
||||
expanded: trigger.getAttribute('aria-expanded'),
|
||||
role: trigger.getAttribute('role'),
|
||||
tabIndex: trigger.tabIndex,
|
||||
icons: Array.from(trigger.querySelectorAll<HTMLElement>('.codicon'), icon => icon.classList.item(1)),
|
||||
})),
|
||||
}, {
|
||||
selectedFolderUri: folderUri.toString(),
|
||||
triggers: [
|
||||
{ label: 'local/project', ariaLabel: 'Folder: local/project', hidden: false, hasPopup: 'listbox', expanded: 'false', role: 'button', tabIndex: 0 },
|
||||
{ label: 'Repo, Issue, or PR', ariaLabel: 'Choose a GitHub target', hidden: false, hasPopup: 'listbox', expanded: 'false', role: 'button', tabIndex: 0 },
|
||||
{ label: 'Remote Setup', ariaLabel: 'Choose a remote setup', hidden: true, hasPopup: 'listbox', expanded: 'false', role: 'button', tabIndex: 0 },
|
||||
{ label: undefined, ariaLabel: 'More workspace options', hidden: false, hasPopup: 'listbox', expanded: 'false', role: 'button', tabIndex: 0 },
|
||||
{ label: 'local/project', ariaLabel: 'Folder: local/project', hidden: false, hasPopup: 'listbox', expanded: 'false', role: 'button', tabIndex: 0, icons: ['codicon-folder', 'codicon-chevron-down-compact'] },
|
||||
{ label: 'Repo, Issue, or PR', ariaLabel: 'Choose a GitHub target', hidden: false, hasPopup: 'listbox', expanded: 'false', role: 'button', tabIndex: 0, icons: ['codicon-github', 'codicon-chevron-down-compact'] },
|
||||
{ label: 'Remote Setup', ariaLabel: 'Choose a remote setup', hidden: true, hasPopup: 'listbox', expanded: 'false', role: 'button', tabIndex: 0, icons: ['codicon-radio-tower', 'codicon-chevron-down-compact'] },
|
||||
{ label: undefined, ariaLabel: 'More workspace options', hidden: false, hasPopup: 'listbox', expanded: 'false', role: 'button', tabIndex: 0, icons: ['codicon-ellipsis', 'codicon-chevron-down-compact'] },
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -2117,7 +2118,7 @@ suite('WorkspacePicker - Category Triggers', () => {
|
||||
const contextTrigger = container.querySelector<HTMLElement>('.action-label');
|
||||
const getContextTriggerSnapshot = () => ({
|
||||
label: contextTrigger?.querySelector('.sessions-chat-dropdown-label')?.textContent,
|
||||
icon: contextTrigger?.querySelector('.codicon')?.className,
|
||||
icon: contextTrigger?.querySelector('.codicon:not(.sessions-chat-dropdown-chevron)')?.className,
|
||||
badge: contextTrigger?.querySelector('.monaco-count-badge')?.textContent,
|
||||
ariaLabel: contextTrigger?.getAttribute('aria-label'),
|
||||
});
|
||||
@@ -2172,7 +2173,7 @@ suite('WorkspacePicker - Category Triggers', () => {
|
||||
}]);
|
||||
const contextTrigger = container.querySelector<HTMLElement>('.action-label');
|
||||
const getContextTriggerSnapshot = () => ({
|
||||
icon: contextTrigger?.querySelector('.codicon')?.className,
|
||||
icon: contextTrigger?.querySelector('.codicon:not(.sessions-chat-dropdown-chevron)')?.className,
|
||||
badge: contextTrigger?.querySelector('.monaco-count-badge')?.textContent,
|
||||
ariaLabel: contextTrigger?.getAttribute('aria-label'),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IObservable } from '../../../../../base/common/observable.js';
|
||||
import { localize } from '../../../../../nls.js';
|
||||
import { ISpotlightPayload, SPOTLIGHT_PRESENTATION_KIND } from '../../../../../workbench/contrib/onboarding/browser/spotlight/spotlightTypes.js';
|
||||
import { IOnboardingScenario } from '../../../../../workbench/contrib/onboarding/common/onboardingScenario.js';
|
||||
import { SessionHarnessPickerInteractiveContext } from '../../../../common/contextkeys.js';
|
||||
import { NEW_SESSION_ONBOARDING_SEEN_KEY } from './newSessionTour.js';
|
||||
import { createNewSessionViewRecentTourWhen, createNewSessionViewWorkspaceStep } from './newSessionViewTourShared.js';
|
||||
|
||||
@@ -30,6 +31,7 @@ const newSessionViewV2Payload: ISpotlightPayload = {
|
||||
placement: 'above',
|
||||
missingTarget: WAIT_FOR_PICKER,
|
||||
openTarget: false,
|
||||
when: SessionHarnessPickerInteractiveContext,
|
||||
allowTargetInteraction: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import assert from 'assert';
|
||||
import { observableValue } from '../../../../../base/common/observable.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
|
||||
import { AgentHostSessionTypesAvailableContext, IsNewChatSessionContext, SessionHasWorkspaceContext } from '../../../../common/contextkeys.js';
|
||||
import { AgentHostSessionTypesAvailableContext, IsNewChatSessionContext, SessionHarnessPickerInteractiveContext, SessionHasWorkspaceContext } from '../../../../common/contextkeys.js';
|
||||
import { createNewSessionViewV2Tour, NEW_SESSION_VIEW_V2_TOUR_ID } from '../../browser/tours/newSessionViewV2Tour.js';
|
||||
import { createNewSessionViewV3Tour } from '../../browser/tours/newSessionViewV3Tour.js';
|
||||
import { NEW_SESSION_ONBOARDING_SEEN_KEY } from '../../browser/tours/newSessionTour.js';
|
||||
@@ -34,6 +34,7 @@ suite('NewSessionViewV2Tour', () => {
|
||||
openTarget: step.openTarget,
|
||||
allowTargetInteraction: step.allowTargetInteraction,
|
||||
advanceWhenWorkspaceSelected: step.advanceWhen === SessionHasWorkspaceContext,
|
||||
requiresInteractiveHarnessPicker: step.when === SessionHarnessPickerInteractiveContext,
|
||||
})),
|
||||
}, {
|
||||
id: NEW_SESSION_VIEW_V2_TOUR_ID,
|
||||
@@ -52,6 +53,7 @@ suite('NewSessionViewV2Tour', () => {
|
||||
openTarget: true,
|
||||
allowTargetInteraction: true,
|
||||
advanceWhenWorkspaceSelected: true,
|
||||
requiresInteractiveHarnessPicker: false,
|
||||
},
|
||||
{
|
||||
id: 'harnessPicker',
|
||||
@@ -60,6 +62,7 @@ suite('NewSessionViewV2Tour', () => {
|
||||
openTarget: false,
|
||||
allowTargetInteraction: true,
|
||||
advanceWhenWorkspaceSelected: false,
|
||||
requiresInteractiveHarnessPicker: true,
|
||||
},
|
||||
{
|
||||
id: 'modelPicker',
|
||||
@@ -68,6 +71,7 @@ suite('NewSessionViewV2Tour', () => {
|
||||
openTarget: true,
|
||||
allowTargetInteraction: true,
|
||||
advanceWhenWorkspaceSelected: false,
|
||||
requiresInteractiveHarnessPicker: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
+5
-7
@@ -2561,6 +2561,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
|
||||
|
||||
protected readonly _onDidChangeCustomizations = this._register(new Emitter<void>());
|
||||
readonly onDidChangeCustomizations = this._onDidChangeCustomizations.event;
|
||||
readonly onDidChangeModels: Event<void>;
|
||||
/** Last-known root config state (schema + values), seeded from `RootState.config`. */
|
||||
protected _rootConfig: RootConfigState | undefined;
|
||||
|
||||
@@ -2775,6 +2776,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
|
||||
@IWorkspaceTrustManagementService protected readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService,
|
||||
) {
|
||||
super();
|
||||
this.onDidChangeModels = Event.defer(Event.any(
|
||||
this._languageModelsService.onDidChangeLanguageModels,
|
||||
this._languageModelsService.onDidChangeModelVisibility,
|
||||
), false, this._store);
|
||||
this._downloadProgress = this._register(this._instantiationService.createInstance(AgentHostDownloadProgress));
|
||||
this._register(toDisposable(() => {
|
||||
for (const cached of this._sessionCache.values()) {
|
||||
@@ -3913,13 +3918,6 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
|
||||
|
||||
// -- Model selection ------------------------------------------------------
|
||||
|
||||
get onDidChangeModels(): Event<void> {
|
||||
return Event.signal(Event.any(
|
||||
this._languageModelsService.onDidChangeLanguageModels,
|
||||
this._languageModelsService.onDidChangeModelVisibility,
|
||||
));
|
||||
}
|
||||
|
||||
getModelsSnapshot(sessionId: string, desiredModelId?: string): ISessionModelsSnapshot {
|
||||
// Agent-host models are registered against the session's resource
|
||||
// scheme (the per-host/per-agent `targetChatSessionType`). Resolve the
|
||||
|
||||
+27
-5
@@ -441,7 +441,7 @@ function createSchemaDefaultConfigurationService(): TestConfigurationService {
|
||||
|
||||
function createProvider(disposables: DisposableStore, agentHostService: MockAgentHostService, contributions = [
|
||||
{ type: 'agent-host-copilotcli', name: 'copilot', displayName: 'Copilot', description: 'test', icon: undefined },
|
||||
], options?: { sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise<ChatSendResult>; acquireOrLoadSession?: (resource: URI) => Promise<IChatModelReference | undefined>; languageModelIds?: string[]; lookupLanguageModel?: (modelId: string) => ILanguageModelChatMetadata | undefined; hiddenLanguageModelIds?: ReadonlySet<string>; languageModelVisibilityChanges?: Event<void>; openSession?: boolean; configurationService?: IConfigurationService; activeSession?: IObservable<IActiveSession | undefined>; visibleSessions?: IObservable<readonly (IActiveSession | undefined)[]>; activeClient?: Omit<SessionActiveClient, 'clientId'>; activeClientAgents?: IObservable<readonly AgentCustomization[]>; activeClientScope?: (sessionType: string, roots: readonly URI[]) => IAgentCustomizationScope; storageService?: IStorageService; isSessionsWindow?: boolean; confirmDelete?: boolean; workspaceTrusted?: boolean; requestWorkspaceTrust?: (uri: URI) => Promise<boolean>; workspaceTrustBarrier?: DeferredPromise<void>; workspaceTrustError?: Error; setUrisTrust?: (uris: URI[], trusted: boolean) => Promise<void>; gitHubService?: IGitHubService; devContainerAgentHostService?: IDevContainerAgentHostService; sessionsProvidersService?: ISessionsProvidersService; pathService?: IPathService; labelService?: ILabelService }): LocalAgentHostSessionsProvider {
|
||||
], options?: { sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise<ChatSendResult>; acquireOrLoadSession?: (resource: URI) => Promise<IChatModelReference | undefined>; languageModelIds?: string[]; lookupLanguageModel?: (modelId: string) => ILanguageModelChatMetadata | undefined; languageModelChanges?: Event<string>; hiddenLanguageModelIds?: ReadonlySet<string>; languageModelVisibilityChanges?: Event<void>; openSession?: boolean; configurationService?: IConfigurationService; activeSession?: IObservable<IActiveSession | undefined>; visibleSessions?: IObservable<readonly (IActiveSession | undefined)[]>; activeClient?: Omit<SessionActiveClient, 'clientId'>; activeClientAgents?: IObservable<readonly AgentCustomization[]>; activeClientScope?: (sessionType: string, roots: readonly URI[]) => IAgentCustomizationScope; storageService?: IStorageService; isSessionsWindow?: boolean; confirmDelete?: boolean; workspaceTrusted?: boolean; requestWorkspaceTrust?: (uri: URI) => Promise<boolean>; workspaceTrustBarrier?: DeferredPromise<void>; workspaceTrustError?: Error; setUrisTrust?: (uris: URI[], trusted: boolean) => Promise<void>; gitHubService?: IGitHubService; devContainerAgentHostService?: IDevContainerAgentHostService; sessionsProvidersService?: ISessionsProvidersService; pathService?: IPathService; labelService?: ILabelService }): LocalAgentHostSessionsProvider {
|
||||
const instantiationService = disposables.add(new TestInstantiationService());
|
||||
|
||||
instantiationService.stub(IAgentHostService, agentHostService);
|
||||
@@ -485,7 +485,7 @@ function createProvider(disposables: DisposableStore, agentHostService: MockAgen
|
||||
lookupLanguageModel: options?.lookupLanguageModel ?? (() => undefined),
|
||||
hasResolvedVendor: () => true,
|
||||
isModelHidden: (modelId: string) => options?.hiddenLanguageModelIds?.has(modelId) ?? false,
|
||||
onDidChangeLanguageModels: Event.None,
|
||||
onDidChangeLanguageModels: options?.languageModelChanges ?? Event.None,
|
||||
onDidChangeModelVisibility: options?.languageModelVisibilityChanges ?? Event.None,
|
||||
});
|
||||
instantiationService.stub(ILabelService, options?.labelService ?? new MockLabelService());
|
||||
@@ -1877,7 +1877,7 @@ suite('LocalAgentHostSessionsProvider', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('getModelsSnapshot excludes hidden models and announces visibility changes', () => {
|
||||
test('getModelsSnapshot excludes hidden models and announces visibility changes', async () => {
|
||||
const matchingModel = { ...createTestLanguageModel('matching'), targetChatSessionType: 'agent-host-copilotcli' };
|
||||
const hiddenLanguageModelIds = new Set(['matching']);
|
||||
const visibilityChanges = disposables.add(new Emitter<void>());
|
||||
@@ -1896,9 +1896,31 @@ suite('LocalAgentHostSessionsProvider', () => {
|
||||
assert.deepStrictEqual(provider.getModelsSnapshot(session.sessionId).models, []);
|
||||
|
||||
hiddenLanguageModelIds.delete('matching');
|
||||
const modelsChanged = Event.toPromise(provider.onDidChangeModels);
|
||||
visibilityChanges.fire();
|
||||
assert.strictEqual(changes, 1);
|
||||
assert.deepStrictEqual(provider.getModelsSnapshot(session.sessionId).models.map(model => model.identifier), ['matching']);
|
||||
const visibleModels = provider.getModelsSnapshot(session.sessionId).models.map(model => model.identifier);
|
||||
await modelsChanged;
|
||||
assert.deepStrictEqual({ changes, visibleModels }, { changes: 1, visibleModels: ['matching'] });
|
||||
});
|
||||
|
||||
test('announces language model changes after the model catalog settles', async () => {
|
||||
const languageModelIds: string[] = [];
|
||||
const languageModelChanges = disposables.add(new Emitter<string>());
|
||||
const provider = createProvider(disposables, agentHost, undefined, {
|
||||
languageModelIds,
|
||||
languageModelChanges: languageModelChanges.event,
|
||||
});
|
||||
|
||||
let modelIdsAtNotification: readonly string[] = [];
|
||||
disposables.add(provider.onDidChangeModels(() => {
|
||||
modelIdsAtNotification = [...languageModelIds];
|
||||
}));
|
||||
const modelsChanged = Event.toPromise(provider.onDidChangeModels);
|
||||
languageModelChanges.fire('agent-host-copilotcli');
|
||||
languageModelIds.push('matching');
|
||||
await modelsChanged;
|
||||
|
||||
assert.deepStrictEqual(modelIdsAtNotification, ['matching']);
|
||||
});
|
||||
|
||||
test('getModelsSnapshot canonicalizes a matching logical-session model identifier', () => {
|
||||
|
||||
+3
-1
@@ -2639,7 +2639,9 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions
|
||||
const gitHubInfo = currentWorkspace?.folders
|
||||
.map(folder => folder.gitRepository?.gitHubInfo.get())
|
||||
.find(info => info !== undefined);
|
||||
const repoId = gitHubInfo ? `${gitHubInfo.owner}/${gitHubInfo.repo}` : undefined;
|
||||
const repoId = gitHubInfo
|
||||
? `${gitHubInfo.owner}/${gitHubInfo.repo}`
|
||||
: currentWorkspace?.folders.map(folder => githubRemoteRepoLabel(folder.root)).find(id => id !== undefined);
|
||||
const selection = await this.commandService.executeCommand<IGitHubContextSelection>(commandId, repoId);
|
||||
if (!selection) {
|
||||
return undefined;
|
||||
|
||||
+65
-1
@@ -8,6 +8,7 @@ import { Codicon } from '../../../../../../base/common/codicons.js';
|
||||
import { Emitter, Event } from '../../../../../../base/common/event.js';
|
||||
import { timeout } from '../../../../../../base/common/async.js';
|
||||
import { DisposableStore, IDisposable, ImmortalReference, toDisposable } from '../../../../../../base/common/lifecycle.js';
|
||||
import { ThemeIcon } from '../../../../../../base/common/themables.js';
|
||||
import { URI } from '../../../../../../base/common/uri.js';
|
||||
import { generateUuid } from '../../../../../../base/common/uuid.js';
|
||||
import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js';
|
||||
@@ -35,7 +36,7 @@ import { IChatResponseModel } from '../../../../../../workbench/contrib/chat/com
|
||||
import { IChatAgentData } from '../../../../../../workbench/contrib/chat/common/participants/chatAgents.js';
|
||||
import { IGitRepository, IGitService } from '../../../../../../workbench/contrib/git/common/gitService.js';
|
||||
import { ISessionChangeEvent } from '../../../../../services/sessions/common/sessionsProvider.js';
|
||||
import { ChatModelSource, GITHUB_REMOTE_FILE_SCHEME, IChat, ISession, SessionStatus } from '../../../../../services/sessions/common/session.js';
|
||||
import { ChatModelSource, GITHUB_REMOTE_FILE_SCHEME, IChat, ISession, ISessionWorkspace, SESSION_WORKSPACE_GROUP_GITHUB, SessionStatus } from '../../../../../services/sessions/common/session.js';
|
||||
import { CloudSandboxEnabledSettingId, type ICloudSandboxCreateSessionRequest } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js';
|
||||
import { RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js';
|
||||
import { CloudSandboxAgentHostContribution, type ICloudSandboxProvisionedSession } from '../../../remoteAgentHost/browser/cloudSandboxAgentHostContribution.js';
|
||||
@@ -59,6 +60,17 @@ import { computePullRequestIcon, GitHubPullRequestState, IGitHubPullRequest } fr
|
||||
|
||||
// ---- Helpers ----------------------------------------------------------------
|
||||
|
||||
interface IGitHubContextBrowseHarness {
|
||||
readonly commandService: Pick<ICommandService, 'executeCommand'>;
|
||||
}
|
||||
|
||||
const browseForGitHubContext = Reflect.get(CopilotChatSessionsProvider.prototype, '_browseForGitHubContext') as (
|
||||
this: IGitHubContextBrowseHarness,
|
||||
commandId: string,
|
||||
icon: ThemeIcon,
|
||||
currentWorkspace: ISessionWorkspace | undefined,
|
||||
) => Promise<ISessionWorkspace | undefined>;
|
||||
|
||||
function createMockAgentSession(resource: URI, opts?: {
|
||||
providerType?: string;
|
||||
title?: string;
|
||||
@@ -431,6 +443,58 @@ suite('CopilotChatSessionsProvider', () => {
|
||||
assert.strictEqual(provider.sessionTypes.length, 1);
|
||||
});
|
||||
|
||||
test('scopes issue and pull request browsing to a selected GitHub repository', async () => {
|
||||
const calls: { commandId: string; repoId: unknown }[] = [];
|
||||
const harness: IGitHubContextBrowseHarness = {
|
||||
commandService: new class extends mock<ICommandService>() {
|
||||
override async executeCommand<T>(commandId: string, repoId?: unknown): Promise<T | undefined> {
|
||||
calls.push({ commandId, repoId });
|
||||
return {
|
||||
repoId: 'cutelyaware/MC4D',
|
||||
url: `https://github.com/cutelyaware/MC4D/${commandId === 'openIssue' ? 'issues/1' : 'pull/2'}`,
|
||||
label: `cutelyaware/MC4D#${commandId === 'openIssue' ? '1' : '2'}`,
|
||||
} as T;
|
||||
}
|
||||
}(),
|
||||
};
|
||||
const repositoryRoot = URI.from({
|
||||
scheme: GITHUB_REMOTE_FILE_SCHEME,
|
||||
authority: 'github',
|
||||
path: '/cutelyaware/MC4D/HEAD',
|
||||
});
|
||||
const workspace: ISessionWorkspace = {
|
||||
uri: URI.parse('https://github.com/cutelyaware/MC4D'),
|
||||
label: 'cutelyaware/MC4D',
|
||||
icon: Codicon.repo,
|
||||
group: SESSION_WORKSPACE_GROUP_GITHUB,
|
||||
folders: [{
|
||||
root: repositoryRoot,
|
||||
workingDirectory: repositoryRoot,
|
||||
name: 'MC4D',
|
||||
description: undefined,
|
||||
gitRepository: undefined,
|
||||
}],
|
||||
requiresWorkspaceTrust: false,
|
||||
isVirtualWorkspace: true,
|
||||
};
|
||||
|
||||
const issue = await browseForGitHubContext.call(harness, 'openIssue', Codicon.issues, workspace);
|
||||
const pullRequest = await browseForGitHubContext.call(harness, 'openPullRequest', Codicon.gitPullRequest, workspace);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
calls,
|
||||
issue: { uri: issue?.uri.toString(), label: issue?.label, icon: issue?.icon.id },
|
||||
pullRequest: { uri: pullRequest?.uri.toString(), label: pullRequest?.label, icon: pullRequest?.icon.id },
|
||||
}, {
|
||||
calls: [
|
||||
{ commandId: 'openIssue', repoId: 'cutelyaware/MC4D' },
|
||||
{ commandId: 'openPullRequest', repoId: 'cutelyaware/MC4D' },
|
||||
],
|
||||
issue: { uri: 'https://github.com/cutelyaware/MC4D/issues/1', label: 'cutelyaware/MC4D#1', icon: Codicon.issues.id },
|
||||
pullRequest: { uri: 'https://github.com/cutelyaware/MC4D/pull/2', label: 'cutelyaware/MC4D#2', icon: Codicon.gitPullRequest.id },
|
||||
});
|
||||
});
|
||||
|
||||
test('sessionTypes excludes Local', () => {
|
||||
const provider = createProvider(disposables, model);
|
||||
assert.ok(!provider.sessionTypes.some(type => type.id === SessionType.Local));
|
||||
|
||||
@@ -181,25 +181,25 @@
|
||||

|
||||
|
||||
#### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Dark
|
||||

|
||||

|
||||
|
||||
#### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Light
|
||||

|
||||

|
||||
|
||||
#### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Dark
|
||||

|
||||

|
||||
|
||||
#### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Light
|
||||

|
||||

|
||||
|
||||
#### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Dark
|
||||

|
||||

|
||||
|
||||
#### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Light
|
||||

|
||||

|
||||
|
||||
#### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Dark
|
||||

|
||||

|
||||
|
||||
#### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Light
|
||||

|
||||

|
||||
|
||||
Reference in New Issue
Block a user