Skip to content
Open
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
35 changes: 29 additions & 6 deletions src/vs/platform/actionWidget/browser/actionList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const previewSelectedActionCommand = 'previewSelectedCodeAction';
export interface IActionListDelegate<T> {
onHide(didCancel?: boolean): void;
onSelect(action: T, preview?: boolean): void;
onFilter?(filter: string, cancellationToken: CancellationToken): Promise<readonly IActionListItem<T>[]>;
onHover?(action: T, cancellationToken: CancellationToken): Promise<{ canPreview: boolean } | void>;
onFocus?(action: T | undefined): void;
}
Expand Down Expand Up @@ -488,6 +489,7 @@ export class ActionListWidget<T> extends Disposable {
private _hasLaidOut = false;
private readonly _filterInput: HTMLInputElement | undefined;
private readonly _filterContainer: HTMLElement | undefined;
private readonly _filterCts = this._register(new MutableDisposable<CancellationTokenSource>());

private readonly _onDidRequestLayout = this._register(new Emitter<void>());

Expand Down Expand Up @@ -622,7 +624,7 @@ export class ActionListWidget<T> extends Disposable {

this._register(dom.addDisposableListener(this._filterInput, 'input', () => {
this._filterText = this._filterInput!.value;
this._applyFilter();
this._applyOrUpdateFilter();
}));
}

Expand Down Expand Up @@ -659,7 +661,7 @@ export class ActionListWidget<T> extends Disposable {
this._filterInput.focus();
this._filterInput.value = e.key;
this._filterText = e.key;
this._applyFilter();
this._applyOrUpdateFilter();
e.preventDefault();
e.stopPropagation();
}
Expand All @@ -677,9 +679,28 @@ export class ActionListWidget<T> extends Disposable {
this._applyFilter();
}

private _applyFilter(): void {
const filterLower = this._filterText.toLowerCase();
const isFiltering = filterLower.length > 0;
private _applyOrUpdateFilter(): void {
if (!this._delegate.onFilter) {
this._applyFilter();
return;
}

const filterText = this._filterText;
this._filterCts.value?.cancel();
const cts = new CancellationTokenSource();
this._filterCts.value = cts;
this._delegate.onFilter(filterText, cts.token).then(items => {
if (cts.token.isCancellationRequested) {
return;
}
this._allMenuItems = [...items];
this._applyFilter(true);
}).catch(() => { /* best-effort */ });
}

private _applyFilter(skipTextFilter = false): void {
const filterLower = skipTextFilter ? '' : this._filterText.toLowerCase();
const isFiltering = !skipTextFilter && filterLower.length > 0;
const visible: IActionListItem<T>[] = [];

// Remember the focused item before splice
Expand Down Expand Up @@ -841,6 +862,8 @@ export class ActionListWidget<T> extends Disposable {
hide(didCancel?: boolean): void {
this._delegate.onHide(didCancel);
this.cts.cancel();
this._filterCts.value?.cancel();
this._filterCts.clear();
this._hover.clear();
this._hideSubmenu();
}
Expand All @@ -849,7 +872,7 @@ export class ActionListWidget<T> extends Disposable {
if (this._filterInput && this._filterText) {
this._filterInput.value = '';
this._filterText = '';
this._applyFilter();
this._applyOrUpdateFilter();
return true;
}
return false;
Expand Down
109 changes: 109 additions & 0 deletions src/vs/platform/actionWidget/test/browser/actionList.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import assert from 'assert';
import { DeferredPromise, timeout } from '../../../../base/common/async.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js';
import { IHoverService } from '../../../hover/browser/hover.js';
import { NullHoverService } from '../../../hover/test/browser/nullHoverService.js';
import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js';
import { MockKeybindingService } from '../../../keybinding/test/common/mockKeybindingService.js';
import { IKeybindingService } from '../../../keybinding/common/keybinding.js';
import { IOpenerService } from '../../../opener/common/opener.js';
import { NullOpenerService } from '../../../opener/test/common/nullOpenerService.js';
import { ActionListItemKind, ActionListWidget, IActionListItem } from '../../browser/actionList.js';

interface ITestActionItem {
readonly id: string;
}

function action(id: string): IActionListItem<ITestActionItem> {
return { kind: ActionListItemKind.Action, label: id, item: { id } };
}

function createActionListWidget(disposables: ReturnType<typeof ensureNoDisposablesAreLeakedInTestSuite>, options: {
readonly onFilter: (filter: string, cancellationToken: CancellationToken) => Promise<readonly IActionListItem<ITestActionItem>[]>;
}): ActionListWidget<ITestActionItem> {
const instantiationService = disposables.add(new TestInstantiationService());
instantiationService.set(IKeybindingService, new MockKeybindingService());
instantiationService.set(IHoverService, NullHoverService);
instantiationService.set(IOpenerService, NullOpenerService);

const widget = disposables.add(instantiationService.createInstance(
ActionListWidget<ITestActionItem>,
'testActionList',
false,
[action('initial')],
{
onHide: () => { },
onSelect: () => { },
onFilter: options.onFilter,
},
undefined,
{ showFilter: true },
));

if (widget.filterContainer) {
document.body.appendChild(widget.filterContainer);
disposables.add({ dispose: () => widget.filterContainer?.remove() });
}
document.body.appendChild(widget.domNode);
disposables.add({ dispose: () => widget.domNode.remove() });
widget.layout(200, 200);

return widget;
}

function typeFilter(widget: ActionListWidget<ITestActionItem>, value: string): void {
assert.ok(widget.filterInput);
widget.filterInput.value = value;
widget.filterInput.dispatchEvent(new Event('input'));
}

suite('ActionListWidget', () => {
const disposables = ensureNoDisposablesAreLeakedInTestSuite();

test('runs dynamic filter updates immediately', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
const filters: string[] = [];
const widget = createActionListWidget(disposables, {
onFilter: async filter => {
filters.push(filter);
return [action(`server-${filter === 'ma' ? 'ranked' : filter}-result`)];
},
});

typeFilter(widget, 'm');
typeFilter(widget, 'ma');
assert.deepStrictEqual(filters, ['m', 'ma']);
await timeout(0);
assert.ok(widget.domNode.textContent?.includes('server-ranked-result'));
}));

test('ignores stale dynamic filter results', async () => {
const firstResult = new DeferredPromise<readonly IActionListItem<ITestActionItem>[]>();
const secondResult = new DeferredPromise<readonly IActionListItem<ITestActionItem>[]>();
const filters: string[] = [];
const widget = createActionListWidget(disposables, {
onFilter: filter => {
filters.push(filter);
return filter === 'm' ? firstResult.p : secondResult.p;
},
});

typeFilter(widget, 'm');
typeFilter(widget, 'ma');
assert.deepStrictEqual(filters, ['m', 'ma']);

firstResult.complete([action('ma-stale-result')]);
await timeout(0);
assert.ok(!widget.domNode.textContent?.includes('ma-stale-result'));

secondResult.complete([action('ma-fresh-result')]);
await timeout(0);
assert.ok(widget.domNode.textContent?.includes('ma-fresh-result'));
});
});
30 changes: 29 additions & 1 deletion src/vs/platform/agentHost/common/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { ISyncedCustomization } from './agentPluginManager.js';
import { IProtectedResourceMetadata } from './state/protocol/state.js';
import type { IActionEnvelope, INotification, ISessionAction, ITerminalAction } from './state/sessionActions.js';
import type { IAgentSubscription } from './state/agentSubscription.js';
import type { ICreateTerminalParams } from './state/protocol/commands.js';
import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from './state/protocol/commands.js';
import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js';
import { AttachmentType, ComponentToState, SessionStatus, StateComponents, type ICustomizationRef, type IPendingMessage, type IRootState, type ISessionInputAnswer, type ISessionInputRequest, type IToolCallResult, type PolicyState, type StringOrMarkdown, SessionInputResponseKind } from './state/sessionState.js';

Expand Down Expand Up @@ -99,10 +99,24 @@ export interface IAgentCreateSessionConfig {
readonly model?: string;
readonly session?: URI;
readonly workingDirectory?: URI;
readonly config?: Record<string, string>;
/** Fork from an existing session at a specific turn index. */
readonly fork?: { readonly session: URI; readonly turnIndex: number };
}

export const AgentHostSessionConfigBranchNameHintKey = 'branchNameHint';

export interface IAgentResolveSessionConfigParams {
readonly provider?: AgentProvider;
readonly workingDirectory?: URI;
readonly config?: Record<string, string>;
}

export interface IAgentSessionConfigCompletionsParams extends IAgentResolveSessionConfigParams {
readonly property: string;
readonly query?: string;
}

/** Serializable attachment passed alongside a message to the agent host. */
export interface IAgentAttachment {
readonly type: AttachmentType;
Expand Down Expand Up @@ -335,6 +349,12 @@ export interface IAgent {
/** Create a new session. Returns server-owned session metadata. */
createSession(config?: IAgentCreateSessionConfig): Promise<IAgentCreateSessionResult>;

/** Resolve the dynamic configuration schema for creating a session. */
resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult>;

/** Return dynamic completions for a session configuration property. */
sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<ISessionConfigCompletionsResult>;

/** Send a user message into an existing session. */
sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[], turnId?: string): Promise<void>;

Expand Down Expand Up @@ -452,6 +472,12 @@ export interface IAgentService {
/** Create a new session. Returns the session URI. */
createSession(config?: IAgentCreateSessionConfig): Promise<URI>;

/** Resolve the dynamic configuration schema for creating a session. */
resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult>;

/** Return dynamic completions for a session configuration property. */
sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<ISessionConfigCompletionsResult>;

/** Dispose a session in the agent host, freeing SDK resources. */
disposeSession(session: URI): Promise<void>;

Expand Down Expand Up @@ -557,6 +583,8 @@ export interface IAgentConnection {
authenticate(params: IAuthenticateParams): Promise<IAuthenticateResult>;
listSessions(): Promise<IAgentSessionMetadata[]>;
createSession(config?: IAgentCreateSessionConfig): Promise<URI>;
resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult>;
sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<ISessionConfigCompletionsResult>;
disposeSession(session: URI): Promise<void>;

// ---- Terminal lifecycle -------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1f72258
7c0c693
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// Generated from types/actions.ts — do not edit
// Run `npm run generate` to regenerate.

import { ActionType, type IStateAction, type IRootAgentsChangedAction, type IRootActiveSessionsChangedAction, type IRootTerminalsChangedAction, type ISessionReadyAction, type ISessionCreationFailedAction, type ISessionTurnStartedAction, type ISessionDeltaAction, type ISessionResponsePartAction, type ISessionToolCallStartAction, type ISessionToolCallDeltaAction, type ISessionToolCallReadyAction, type ISessionToolCallConfirmedAction, type ISessionToolCallCompleteAction, type ISessionToolCallResultConfirmedAction, type ISessionToolCallContentChangedAction, type ISessionTurnCompleteAction, type ISessionTurnCancelledAction, type ISessionErrorAction, type ISessionTitleChangedAction, type ISessionUsageAction, type ISessionReasoningAction, type ISessionModelChangedAction, type ISessionServerToolsChangedAction, type ISessionActiveClientChangedAction, type ISessionActiveClientToolsChangedAction, type ISessionPendingMessageSetAction, type ISessionPendingMessageRemovedAction, type ISessionQueuedMessagesReorderedAction, type ISessionInputRequestedAction, type ISessionInputAnswerChangedAction, type ISessionInputCompletedAction, type ISessionCustomizationsChangedAction, type ISessionCustomizationToggledAction, type ISessionTruncatedAction, type ISessionIsReadChangedAction, type ISessionIsDoneChangedAction, type ISessionDiffsChangedAction, type ITerminalDataAction, type ITerminalInputAction, type ITerminalResizedAction, type ITerminalClaimedAction, type ITerminalTitleChangedAction, type ITerminalCwdChangedAction, type ITerminalExitedAction, type ITerminalClearedAction } from './actions.js';
import { ActionType, type IStateAction, type IRootAgentsChangedAction, type IRootActiveSessionsChangedAction, type IRootTerminalsChangedAction, type ISessionReadyAction, type ISessionCreationFailedAction, type ISessionTurnStartedAction, type ISessionDeltaAction, type ISessionResponsePartAction, type ISessionToolCallStartAction, type ISessionToolCallDeltaAction, type ISessionToolCallReadyAction, type ISessionToolCallConfirmedAction, type ISessionToolCallCompleteAction, type ISessionToolCallResultConfirmedAction, type ISessionToolCallContentChangedAction, type ISessionTurnCompleteAction, type ISessionTurnCancelledAction, type ISessionErrorAction, type ISessionTitleChangedAction, type ISessionUsageAction, type ISessionReasoningAction, type ISessionModelChangedAction, type ISessionServerToolsChangedAction, type ISessionActiveClientChangedAction, type ISessionActiveClientToolsChangedAction, type ISessionPendingMessageSetAction, type ISessionPendingMessageRemovedAction, type ISessionQueuedMessagesReorderedAction, type ISessionInputRequestedAction, type ISessionInputAnswerChangedAction, type ISessionInputCompletedAction, type ISessionCustomizationsChangedAction, type ISessionCustomizationToggledAction, type ISessionTruncatedAction, type ISessionIsReadChangedAction, type ISessionIsDoneChangedAction, type ISessionDiffsChangedAction, type ISessionConfigChangedAction, type ITerminalDataAction, type ITerminalInputAction, type ITerminalResizedAction, type ITerminalClaimedAction, type ITerminalTitleChangedAction, type ITerminalCwdChangedAction, type ITerminalExitedAction, type ITerminalClearedAction } from './actions.js';


// ─── Root vs Session vs Terminal Action Unions ───────────────────────────────
Expand Down Expand Up @@ -57,6 +57,7 @@ export type ISessionAction =
| ISessionIsReadChangedAction
| ISessionIsDoneChangedAction
| ISessionDiffsChangedAction
| ISessionConfigChangedAction
;

/** Union of session actions that clients may dispatch. */
Expand All @@ -79,6 +80,7 @@ export type IClientSessionAction =
| ISessionTruncatedAction
| ISessionIsReadChangedAction
| ISessionIsDoneChangedAction
| ISessionConfigChangedAction
;

/** Union of session actions that only the server may produce. */
Expand Down Expand Up @@ -173,6 +175,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in IStateAction['type']]: boo
[ActionType.SessionIsReadChanged]: true,
[ActionType.SessionIsDoneChanged]: true,
[ActionType.SessionDiffsChanged]: false,
[ActionType.SessionConfigChanged]: true,
[ActionType.TerminalData]: false,
[ActionType.TerminalInput]: true,
[ActionType.TerminalResized]: true,
Expand Down
23 changes: 23 additions & 0 deletions src/vs/platform/agentHost/common/state/protocol/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const enum ActionType {
SessionIsReadChanged = 'session/isReadChanged',
SessionIsDoneChanged = 'session/isDoneChanged',
SessionDiffsChanged = 'session/diffsChanged',
SessionConfigChanged = 'session/configChanged',
RootTerminalsChanged = 'root/terminalsChanged',
TerminalData = 'terminal/data',
TerminalInput = 'terminal/input',
Expand Down Expand Up @@ -663,6 +664,27 @@ export interface ISessionCustomizationToggledAction {
enabled: boolean;
}

// ─── Config Actions ──────────────────────────────────────────────────────────

/**
* Client changed a mutable config value mid-session.
*
* Only properties with `sessionMutable: true` in the config schema may be
* changed. The server validates and broadcasts the action; the reducer merges
* the new values into `state.config.values`.
*
* @category Session Actions
* @version 1
* @clientDispatchable
*/
export interface ISessionConfigChangedAction {
type: ActionType.SessionConfigChanged;
/** Session URI */
session: URI;
/** Updated config values (merged into existing config) */
config: Record<string, string>;
}

// ─── Truncation ──────────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -1006,6 +1028,7 @@ export type IStateAction =
| ISessionIsReadChangedAction
| ISessionIsDoneChangedAction
| ISessionDiffsChangedAction
| ISessionConfigChangedAction
| ITerminalDataAction
| ITerminalInputAction
| ITerminalResizedAction
Expand Down
Loading
Loading