diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md
index 122dd30cda1e33..eaff5dc16c15ec 100644
--- a/.github/skills/sessions/SKILL.md
+++ b/.github/skills/sessions/SKILL.md
@@ -134,6 +134,12 @@ Whenever the user flags a wrong pattern, rejects an approach, or gives design/ru
- **`DetailPanelController` must not hide the detail on an empty editor group in the new-session view**: `_computeTarget` returns `Hidden` when the main editor part is empty (a created session's all-tabs-closed → whole side pane closed). But the new-session (uncreated) view's editor group is *transiently* empty while its Files tab is (re)ensured, and its Files detail is open by default and owned by the layout controller's D3b. Gating the empty-group `Hidden` on `activeSession.isCreated` avoids a transient hide that the D2 visibility listener would otherwise capture as the new-session preference — making every subsequent `cmd+n` open with the side pane hidden. Combined with the editor-visibility reveal gate, the new-session default stays open while a user's explicit hide is still remembered (D3b `_newSessionViewState`).
+- **Don't mirror an environment fact onto a shared widget API**: an Agents-window check (e.g. "is this the sessions window?") belongs on `IWorkbenchEnvironmentService.isSessionsWindow`, read directly by the consumer that needs it (e.g. `getChatAccessibilityHelpProvider` in `chatAccessibilityHelp.ts` injecting the service itself). Do not add an `isSessionsWindow`-style property to a shared interface like `IChatWidget` just to thread that fact through — it leaks a sessions-specific concept into shared workbench chat surfaces and every future consumer would need the same plumbing instead of injecting the service once.
+
+- **A view-lifecycle `setChat`/`setModel`-style hook can be re-invoked for the *same* underlying resource**: `ChatView.setChat` fires again on unrelated status/interactivity observable changes, not only on a genuine view swap. A consumer like `ResponseSelectionSideChatController` that force-dismisses its own transient UI on every call discards an in-progress draft and, worse, clears a pending busy submission mid-flight, letting a duplicate submission race in. Compare the incoming resource against the previously tracked one and only treat a genuine change (or the first call) as dismiss-worthy; a same-resource re-invocation must preserve visible/busy state.
+
+- **A pending async submission's completion/error handler must no-op after a genuine force-dismiss, not just after a same-resource re-invocation**: even with the same-resource guard above, `ResponseSelectionSideChatController._submit`'s `createAndSendSideChat().then()/.catch()` can still settle *after* the user has genuinely navigated away (a different-resource `setChat`, or any other force-dismiss) — reopening the overlay, restoring the typed query, refocusing the input, or showing a stale error notification for UI the user already dismissed. Capture a `_generation` counter bumped only on a genuine force-dismiss, snapshot it before the async call, and have the settle handlers bail when the counter no longer matches; don't rely solely on resource comparison, since the overlay's own dismissed state (not the chat identity) is what must gate the mutation.
+
## Validating Changes
You **must** run these checks before declaring work complete:
diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-side-chat-receives-bounded-source-context-without-copied-history.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-side-chat-receives-bounded-source-context-without-copied-history.yaml
index 6d184384600558..6f472918e9fe0d 100644
--- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-side-chat-receives-bounded-source-context-without-copied-history.yaml
+++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-side-chat-receives-bounded-source-context-without-copied-history.yaml
@@ -8,11 +8,11 @@ exchanges:
- role: user
content: Remember the exact token SIDECHAT42 for a later question. Reply with exactly "ready".
response:
- content: ready
+ content: |-
+ I'll remember the token SIDECHAT42.
+
+ ready
stopReason: end_turn
- usage:
- inputTokens: 2686
- outputTokens: 4
- request:
model: claude-opus-4.8
system: ${system}
@@ -20,12 +20,22 @@ exchanges:
- role: user
content: Remember the exact token SIDECHAT42 for a later question. Reply with exactly "ready".
- role: assistant
- content: ready
+ content: |-
+ I'll remember the token SIDECHAT42.
+
+ ready
- role: user
- content: What exact token did I ask you to remember? Reply with only the token.
+ content: |-
+
+ length=159
+ This is a side conversation. Prefer explanation over action; do not make changes or carry out work unless the user explicitly asks.
+
+ Selected text:
+
+ MOONVALE99
+
+
+ Reply with the exact remembered token, then a space, then the exact selected text given to you as context — nothing else.
response:
- content: SIDECHAT42
+ content: SIDECHAT42 MOONVALE99
stopReason: end_turn
- usage:
- inputTokens: 2
- outputTokens: 10
diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-side-chat-receives-bounded-source-context-without-copied-history.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-side-chat-receives-bounded-source-context-without-copied-history.yaml
index 2507af9915fbf1..49ee218285b10d 100644
--- a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-side-chat-receives-bounded-source-context-without-copied-history.yaml
+++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-side-chat-receives-bounded-source-context-without-copied-history.yaml
@@ -2,7 +2,7 @@ version: 1
dialect: anthropic
exchanges:
- request:
- model: claude-haiku-4.5
+ model: claude-sonnet-5
system: ${system}
messages:
- role: user
@@ -10,11 +10,8 @@ exchanges:
response:
content: ready
stopReason: end_turn
- usage:
- inputTokens: 9
- outputTokens: 65
- request:
- model: claude-haiku-4.5
+ model: claude-sonnet-5
system: ${system}
messages:
- role: user
@@ -22,10 +19,17 @@ exchanges:
- role: assistant
content: ready
- role: user
- content: What exact token did I ask you to remember? Reply with only the token.
+ content: |-
+
+ length=159
+ This is a side conversation. Prefer explanation over action; do not make changes or carry out work unless the user explicitly asks.
+
+ Selected text:
+
+ MOONVALE99
+
+
+ Reply with the exact remembered token, then a space, then the exact selected text given to you as context — nothing else.
response:
- content: SIDECHAT42
+ content: SIDECHAT42 MOONVALE99
stopReason: end_turn
- usage:
- inputTokens: 9
- outputTokens: 61
diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts
index 04856a668245ff..561638db6e3449 100644
--- a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts
+++ b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts
@@ -47,7 +47,7 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void {
return { sessionUri, defaultChatUri: buildDefaultChatUri(sessionUri), workspace };
}
- async function createPeer(sessionUri: string, id: string, source?: { chat: string; turnId: string; kind: ChatSourceKind }): Promise {
+ async function createPeer(sessionUri: string, id: string, source?: { chat: string; turnId: string; kind: ChatSourceKind; selection?: { text: string; responsePartId?: string } }): Promise {
const chat = buildChatUri(sessionUri, id);
await context.client.call('createChat', {
channel: sessionUri,
@@ -630,14 +630,17 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void {
const { sessionUri, defaultChatUri } = await createSession('side-context');
await driveTurn(defaultChatUri, 'turn-source', 'Remember the exact token SIDECHAT42 for a later question. Reply with exactly "ready".', 1);
+ const selection = { text: 'MOONVALE99', responsePartId: 'response-part-source-1' };
const sideChatUri = await createPeer(sessionUri, 'side', {
kind: ChatSourceKind.SideChat,
chat: defaultChatUri,
turnId: 'turn-source',
+ selection,
});
await context.client.call('subscribe', { channel: sideChatUri });
- const response = await driveTurn(sideChatUri, 'turn-side', 'What exact token did I ask you to remember? Reply with only the token.', 2);
+ const question = 'Reply with the exact remembered token, then a space, then the exact selected text given to you as context — nothing else.';
+ const response = await driveTurn(sideChatUri, 'turn-side', question, 2);
const [sourceState, sideState, session] = await Promise.all([
chatState(defaultChatUri),
chatState(sideChatUri),
@@ -645,18 +648,20 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void {
]);
assert.deepStrictEqual({
- responseIncludesCode: /SIDECHAT42/i.test(response),
+ responseIncludesRememberedToken: /SIDECHAT42/i.test(response),
+ responseIncludesSelectedText: /MOONVALE99/i.test(response),
sourceTurnCount: sourceState.turns.length,
sideTurnCount: sideState.turns.length,
origin: session.chats.find(chat => chat.resource === sideChatUri)?.origin,
firstMessage: sideState.turns[0]?.message.text,
firstAttachments: sideState.turns[0]?.message.attachments ?? [],
}, {
- responseIncludesCode: true,
+ responseIncludesRememberedToken: true,
+ responseIncludesSelectedText: true,
sourceTurnCount: 1,
sideTurnCount: 1,
- origin: { kind: ChatOriginKind.SideChat, chat: defaultChatUri, turnId: 'turn-source' },
- firstMessage: 'What exact token did I ask you to remember? Reply with only the token.',
+ origin: { kind: ChatOriginKind.SideChat, chat: defaultChatUri, turnId: 'turn-source', selection },
+ firstMessage: question,
firstAttachments: [],
});
}, config.supportsMultipleChats && !!config.supportsSideChats);
diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md
index fb07c5c2a0fa4f..4f733aec2b74d6 100644
--- a/src/vs/sessions/SESSIONS.md
+++ b/src/vs/sessions/SESSIONS.md
@@ -601,6 +601,92 @@ channel that received `ChatToolCallStart`/`ChatToolCallReady`; confirmations sen
to the parent session URI are invalid and will not resolve the SDK permission
request.
+##### Direct selection invocation (Agents window only)
+
+Besides typing `/btw`, a user can select assistant markdown text in a chat
+response and get an inline "Ask Question" affordance that creates the same
+kind of side chat directly from that selection. This is Agents-window-only —
+it never appears in the regular workbench chat surface — and reuses
+`ISessionsManagementService.createSideChatInSession`/`sendRequest` and
+`ISessionsService.openChat`, the identical plumbing `/btw` uses (see
+`sideChatOrchestration.ts`'s `createAndSendSideChat`/`openAndSendSideChat`
+helpers, shared by both entry points).
+
+`ResponseSelectionSideChatController` (`contrib/chat/browser/`) is owned by
+`ChatView` per chat widget. It listens for `selectionchange` on the widget's
+document and resolves the selection via `resolveResponseSelection`
+(`responseSelectionResolver.ts`), which only accepts a selection when:
+- both selection endpoints fall inside the **same** assistant response
+ (resolved through `IChatWidget.getElementFromNode`, `isResponseVM`), and
+- the selection stays within that response's rendered markdown
+ (`.chat-markdown-part`), excluding any embedded Monaco editor
+ (`.monaco-editor`) or tool-invocation UI (`.chat-tool-invocation-part`).
+
+A resolved selection shows an "Ask Question" input positioned under the
+selection, reusing the same visual/input component as the editor's feedback
+affordance: `FeedbackInputWidget` (`contrib/agentFeedback/browser/
+feedbackInputWidget.ts`), extracted from `AgentFeedbackInputWidget` so both
+consumers share one textarea/action-bar implementation. Submitting creates a
+side chat anchored to the **selected response's turn**
+(`IChatResponseViewModel.requestId`, not the chat's last turn) with
+`selection.text` set to the exact selected text, mirroring `/btw`'s
+"inherits model/agent, immutable selection snapshot" semantics. The same
+runtime capability/status gate as `/btw` applies before creating the side
+chat (`session.capabilities.get().supportsSideChat`, not `Untitled`, not
+archived); failing that gate shows a warning notification instead of
+creating a partially-supported side chat.
+
+Submitting does not eagerly dismiss the overlay: it stays visible with a busy
+state (`FeedbackInputWidget.setBusy(true, statusLabel)` — disabled input,
+hidden action bar, a spinning `Codicon.loading` indicator, `aria-busy` plus an
+`aria.status` announcement) while the create/open/send orchestration is
+in-flight, and duplicate submission is blocked both by the disabled action and
+an explicit `isBusy` guard in `_submit`. Opening the newly-created side chat
+naturally dismisses the overlay via `setChat`, which force-dismisses only when
+the chat's *resource* actually changes: `ChatView` re-invokes `setChat` for the
+same chat on unrelated status/interactivity observable updates, so a
+same-resource call must preserve a visible draft and, critically, a pending
+busy submission rather than clearing it and letting a duplicate race in. On
+failure the busy state clears, the typed question and normal controls are
+restored, and the input is refocused so the user can retry without losing
+their text — the existing warning/error notification and log call are
+unchanged. A completion/error that settles after a genuine chat navigation
+already force-dismissed the overlay (`setChat` with a different chat resource)
+is tracked via a submission generation counter bumped on that force-dismiss,
+so the stale handler no-ops instead of reopening, refocusing, or mutating the
+now-unrelated overlay. Escape, scrolling, and selection invalidation are all
+ignored while a submission is pending so they cannot race the in-flight
+request. The action-bar slot and the spinner that replaces it both size to the
+widget's shared `_LINE_HEIGHT` constant (applied as an inline style to each
+element, matching the textarea's line-height) and center via flex, rather than
+a hardcoded icon height or a positional transform — this keeps both optically
+centered on the input's single line, and still flush to the last line when the
+textarea grows multi-line (the row keeps `align-items: flex-end`).
+
+The `selectionchange` listener ignores events entirely while focus is inside
+the "Ask Question" input (`dom.isAncestorOfActiveElement`): focusing the
+textarea collapses the browser's native document selection as a side effect,
+and without this guard that collapse would dismiss the very input the user
+just focused. The captured selection is treated as an immutable snapshot for
+that reason — it is not re-read from the live DOM selection on submit.
+Escape (or any other dismissal while the input has focus) restores focus to
+the source response via `IChatWidget.focusResponseItem(true)` rather than
+letting it fall through to the document body. Plain Enter submits and
+prevents the default newline; Shift+Enter and Enter during IME composition
+(`e.browserEvent.isComposing`) are left alone so the textarea inserts a
+newline or lets composition finish. The overlay's position clamps
+both horizontally and vertically against the chat widget's own bounds and the
+visible viewport, measured after `FeedbackInputWidget.show()`/`autoSize()` so
+real dimensions are used; when there isn't room below the selection it flips
+above instead, falling back to the nearest in-bounds edge only when neither
+placement fully fits.
+
+`FeedbackInputWidget.setPlaceholder` only derives `aria-label` from the
+placeholder when the widget was constructed without an explicit
+`options.ariaLabel`; a caller (like this controller, which sets a dedicated
+accessible name) keeps its configured `aria-label` untouched across
+placeholder changes.
+
Agent-host approval levels map to the Copilot SDK allow-all modes before each
turn: Default approvals uses `off`, Allow all uses `on`, and Assisted permissions
uses `auto`. Assisted permissions only skips a prompt when the SDK's
diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts
index bb34b613afecaf..4dc283d84c020f 100644
--- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts
+++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts
@@ -14,24 +14,22 @@ import { EditorOption } from '../../../../editor/common/config/editorOptions.js'
import { Position } from '../../../../editor/common/core/position.js';
import { Range } from '../../../../editor/common/core/range.js';
import { Selection, SelectionDirection } from '../../../../editor/common/core/selection.js';
-import { addStandardDisposableListener, getWindow, isHTMLElement, ModifierKeyEmitter } from '../../../../base/browser/dom.js';
+import { addStandardDisposableListener, getWindow, isHTMLElement } from '../../../../base/browser/dom.js';
import { URI } from '../../../../base/common/uri.js';
import { isEqual } from '../../../../base/common/resources.js';
import { KeyCode } from '../../../../base/common/keyCodes.js';
import { IAgentFeedbackService } from './agentFeedbackService.js';
import { createAgentFeedbackContext } from './agentFeedbackEditorUtils.js';
import { localize, localize2 } from '../../../../nls.js';
-import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';
-import { Action } from '../../../../base/common/actions.js';
import { Codicon } from '../../../../base/common/codicons.js';
-import { ThemeIcon } from '../../../../base/common/themables.js';
-import { Emitter, Event } from '../../../../base/common/event.js';
+import { Event } from '../../../../base/common/event.js';
import { ISession } from '../../../services/sessions/common/session.js';
import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js';
import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js';
+import { FeedbackInputWidget } from './feedbackInputWidget.js';
const addFeedbackAtCurrentLineActionId = 'agentFeedbackEditor.action.addAtCurrentLine';
const agentFeedbackHoverGlyphClassName = 'agent-feedback-glyph';
@@ -40,113 +38,41 @@ const hasAgentFeedbackSessionForEditor = new RawContextKey('agentFeedba
/**
* The inline "Add Feedback" input shown in the editor when the user selects a
* range to comment on. Exported so it can be rendered in a component fixture;
- * it only depends on {@link ICodeEditor} for its layout geometry.
+ * it only depends on {@link ICodeEditor} for its layout geometry. Wraps the
+ * reusable {@link FeedbackInputWidget} core as an {@link IOverlayWidget}.
*/
export class AgentFeedbackInputWidget extends Disposable implements IOverlayWidget {
private static readonly _ID = 'agentFeedback.inputWidget';
- private static readonly _MIN_WIDTH = 150;
- private static readonly _MAX_WIDTH = 400;
- // The input should never be wider than the editor itself. Cap it to this
- // fraction of the editor width so it doesn't render past the editor bounds
- // on narrow editors.
- private static readonly _MAX_WIDTH_EDITOR_FRACTION = 0.9;
readonly allowEditorOverflow = false;
- private readonly _domNode: HTMLElement;
- private readonly _inputElement: HTMLTextAreaElement;
- private readonly _measureElement: HTMLElement;
- private readonly _actionBar: ActionBar;
- private readonly _addAction: Action;
- private readonly _addAndSubmitAction: Action;
+ private readonly _core: FeedbackInputWidget;
private _position: IOverlayWidgetPosition | null = null;
- private _lineHeight = 0;
- private readonly _onDidTriggerAdd = this._register(new Emitter());
- readonly onDidTriggerAdd: Event = this._onDidTriggerAdd.event;
-
- private readonly _onDidTriggerAddAndSubmit = this._register(new Emitter());
- readonly onDidTriggerAddAndSubmit: Event = this._onDidTriggerAddAndSubmit.event;
+ readonly onDidTriggerAdd: Event;
+ readonly onDidTriggerAddAndSubmit: Event;
constructor(
private readonly _editor: ICodeEditor,
) {
super();
- this._domNode = document.createElement('div');
- this._domNode.classList.add('agent-feedback-input-widget');
- this._domNode.style.display = 'none';
-
- this._inputElement = document.createElement('textarea');
- this._inputElement.rows = 1;
- this._inputElement.placeholder = localize('agentFeedback.addFeedback', "Add Feedback");
- this._domNode.appendChild(this._inputElement);
-
- // Hidden element used to measure text width for auto-growing
- this._measureElement = document.createElement('span');
- this._measureElement.classList.add('agent-feedback-input-measure');
- this._domNode.appendChild(this._measureElement);
-
- // Action bar with add/submit actions
- const actionsContainer = document.createElement('div');
- actionsContainer.classList.add('agent-feedback-input-actions');
- this._domNode.appendChild(actionsContainer);
-
- this._addAction = this._register(new Action(
- 'agentFeedback.add',
- localize('agentFeedback.add', "Add Feedback"),
- ThemeIcon.asClassName(Codicon.plus),
- false,
- () => { this._onDidTriggerAdd.fire(); return Promise.resolve(); }
- ));
-
- this._addAndSubmitAction = this._register(new Action(
- 'agentFeedback.addAndSubmit',
- localize('agentFeedback.addAndSubmit', "Add Feedback and Submit"),
- ThemeIcon.asClassName(Codicon.send),
- false,
- () => { this._onDidTriggerAddAndSubmit.fire(); return Promise.resolve(); }
- ));
-
- this._actionBar = this._register(new ActionBar(actionsContainer));
- this._actionBar.push(this._addAction, { icon: true, label: false, keybinding: localize('enter', "Enter") });
-
- // Toggle to alt action when Alt key is held
- const modifierKeyEmitter = ModifierKeyEmitter.getInstance();
- this._register(modifierKeyEmitter.event(status => {
- this._updateActionForAlt(status.altKey);
- }));
-
- // Focus the input when clicking anywhere on the widget that isn't the
- // textarea itself or the action bar (e.g. padding around the textarea).
- this._register(addStandardDisposableListener(this._domNode, 'mousedown', e => {
- const target = e.target as Node | null;
- if (target === this._inputElement) {
- return;
- }
- if (actionsContainer.contains(target)) {
- return;
- }
- e.preventDefault();
- this._inputElement.focus();
+ this._core = this._register(new FeedbackInputWidget({
+ placeholder: localize('agentFeedback.addFeedback', "Add Feedback"),
+ getMaxContentWidth: () => this._computeContentWidth(),
+ primaryAction: {
+ label: localize('agentFeedback.add', "Add Feedback"),
+ icon: Codicon.plus,
+ keybindingLabel: localize('enter', "Enter"),
+ },
+ secondaryAction: {
+ label: localize('agentFeedback.addAndSubmit', "Add Feedback and Submit"),
+ icon: Codicon.send,
+ keybindingLabel: localize('altEnter', "Alt+Enter"),
+ },
}));
-
- this._lineHeight = 22;
- this._inputElement.style.lineHeight = `${this._lineHeight}px`;
- }
-
- private _isShowingAlt = false;
-
- private _updateActionForAlt(altKey: boolean): void {
- if (altKey && !this._isShowingAlt) {
- this._isShowingAlt = true;
- this._actionBar.clear();
- this._actionBar.push(this._addAndSubmitAction, { icon: true, label: false, keybinding: localize('altEnter', "Alt+Enter") });
- } else if (!altKey && this._isShowingAlt) {
- this._isShowingAlt = false;
- this._actionBar.clear();
- this._actionBar.push(this._addAction, { icon: true, label: false, keybinding: localize('enter', "Enter") });
- }
+ this.onDidTriggerAdd = this._core.onDidTriggerPrimary;
+ this.onDidTriggerAddAndSubmit = this._core.onDidTriggerSecondary;
}
getId(): string {
@@ -154,7 +80,7 @@ export class AgentFeedbackInputWidget extends Disposable implements IOverlayWidg
}
getDomNode(): HTMLElement {
- return this._domNode;
+ return this._core.domNode;
}
getPosition(): IOverlayWidgetPosition | null {
@@ -162,7 +88,7 @@ export class AgentFeedbackInputWidget extends Disposable implements IOverlayWidg
}
get inputElement(): HTMLTextAreaElement {
- return this._inputElement;
+ return this._core.inputElement;
}
setPosition(position: IOverlayWidgetPosition | null): void {
@@ -171,75 +97,36 @@ export class AgentFeedbackInputWidget extends Disposable implements IOverlayWidg
}
show(): void {
- this._domNode.style.display = '';
+ this._core.show();
}
hide(): void {
- this._domNode.style.display = 'none';
+ this._core.hide();
}
clearInput(): void {
- this._inputElement.value = '';
- this._updateActionEnabled();
- this._autoSize();
+ this._core.clearInput();
}
setPlaceholder(placeholder: string): void {
- if (this._inputElement.placeholder === placeholder) {
- return;
- }
- this._inputElement.placeholder = placeholder;
- this._autoSize();
+ this._core.setPlaceholder(placeholder);
}
autoSize(): void {
- this._autoSize();
+ this._core.autoSize();
}
updateActionEnabled(): void {
- this._updateActionEnabled();
- }
-
- private _updateActionEnabled(): void {
- const hasText = this._inputElement.value.trim().length > 0;
- this._addAction.enabled = hasText;
- this._addAndSubmitAction.enabled = hasText;
+ this._core.updateActionEnabled();
}
- private _autoSize(): void {
- const text = this._inputElement.value || this._inputElement.placeholder;
-
- // Measure the text width using the hidden span
- this._measureElement.textContent = text;
- const textWidth = this._measureElement.scrollWidth;
-
- // Clamp width between min and a max that never exceeds the editor width.
- // On very narrow editors the max can drop below the nominal minimum, so
- // derive an effective minimum that never exceeds the max and apply it
- // inline to override the CSS `min-width` (otherwise the textarea would be
- // forced back up to its CSS minimum and overflow the editor).
- const maxWidth = this._computeMaxWidth();
- const minWidth = Math.min(AgentFeedbackInputWidget._MIN_WIDTH, maxWidth);
- const desiredWidth = Math.max(minWidth, textWidth + 10);
- const width = Math.min(desiredWidth, maxWidth);
- this._inputElement.style.minWidth = `${minWidth}px`;
- this._inputElement.style.width = `${width}px`;
-
- // Reset height to auto then expand to fit all content, with a minimum of 1 line
- this._inputElement.style.height = 'auto';
- const newHeight = Math.max(this._inputElement.scrollHeight, this._lineHeight);
- this._inputElement.style.height = `${newHeight}px`;
- }
-
- private _computeMaxWidth(): number {
+ private _computeContentWidth(): number {
// The widget sticks to the editor's content left edge, so the space it
// has available is the content area width (to the right of the line
// numbers/glyph margin), not the full editor width.
const layoutInfo = this._editor.getLayoutInfo();
- const contentWidth = Math.max(0, layoutInfo.width - layoutInfo.contentLeft);
- return Math.min(AgentFeedbackInputWidget._MAX_WIDTH, contentWidth * AgentFeedbackInputWidget._MAX_WIDTH_EDITOR_FRACTION);
+ return Math.max(0, layoutInfo.width - layoutInfo.contentLeft);
}
-
}
export class AgentFeedbackEditorInputContribution extends Disposable implements IEditorContribution {
diff --git a/src/vs/sessions/contrib/agentFeedback/browser/feedbackInputWidget.ts b/src/vs/sessions/contrib/agentFeedback/browser/feedbackInputWidget.ts
new file mode 100644
index 00000000000000..ad61578d1d0d71
--- /dev/null
+++ b/src/vs/sessions/contrib/agentFeedback/browser/feedbackInputWidget.ts
@@ -0,0 +1,236 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import './media/agentFeedbackEditorInput.css';
+import { addStandardDisposableListener, ModifierKeyEmitter } from '../../../../base/browser/dom.js';
+import { status as announceStatus } from '../../../../base/browser/ui/aria/aria.js';
+import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';
+import { Action } from '../../../../base/common/actions.js';
+import { Codicon } from '../../../../base/common/codicons.js';
+import { ThemeIcon } from '../../../../base/common/themables.js';
+import { Emitter, Event } from '../../../../base/common/event.js';
+import { Disposable } from '../../../../base/common/lifecycle.js';
+
+export interface IFeedbackInputWidgetAction {
+ readonly label: string;
+ readonly icon: ThemeIcon;
+ readonly keybindingLabel: string;
+}
+
+export interface IFeedbackInputWidgetOptions {
+ readonly placeholder: string;
+ readonly ariaLabel?: string;
+ /** Returns the available content width (e.g. editor content width, or a host container's width) to clamp against. */
+ readonly getMaxContentWidth: () => number;
+ readonly primaryAction: IFeedbackInputWidgetAction;
+ /** When provided, holding Alt swaps the visible action to this one. */
+ readonly secondaryAction?: IFeedbackInputWidgetAction;
+}
+
+/**
+ * Reusable auto-sizing textarea + action bar shared by the editor "Add
+ * Feedback" overlay and the Agents-window response-selection "Ask Question"
+ * affordance. Positioning and hosting (overlay widget vs. absolute DOM child)
+ * are left to the caller; this only owns the DOM structure, sizing and input.
+ */
+export class FeedbackInputWidget extends Disposable {
+
+ private static readonly _MIN_WIDTH = 150;
+ private static readonly _MAX_WIDTH = 400;
+ // The input should never be wider than its host. Cap it to this fraction of
+ // the available width so it doesn't render past the host bounds when narrow.
+ private static readonly _MAX_WIDTH_HOST_FRACTION = 0.9;
+
+ private static readonly _LINE_HEIGHT = 22;
+
+ readonly domNode: HTMLElement;
+ readonly inputElement: HTMLTextAreaElement;
+ private readonly _measureElement: HTMLElement;
+ private readonly _actionsContainer: HTMLElement;
+ private readonly _busyIndicator: HTMLElement;
+ private readonly _actionBar: ActionBar;
+ private readonly _primaryAction: Action;
+ private readonly _secondaryAction: Action | undefined;
+ private _isShowingSecondary = false;
+ private _busy = false;
+ private readonly _hasExplicitAriaLabel: boolean;
+
+ /** Whether {@link setBusy} is currently active; callers must not submit again while true. */
+ get isBusy(): boolean {
+ return this._busy;
+ }
+
+ private readonly _onDidTriggerPrimary = this._register(new Emitter());
+ readonly onDidTriggerPrimary: Event = this._onDidTriggerPrimary.event;
+
+ private readonly _onDidTriggerSecondary = this._register(new Emitter());
+ readonly onDidTriggerSecondary: Event = this._onDidTriggerSecondary.event;
+
+ constructor(private readonly _options: IFeedbackInputWidgetOptions) {
+ super();
+ this._hasExplicitAriaLabel = _options.ariaLabel !== undefined;
+ this.domNode = document.createElement('div');
+ this.domNode.classList.add('agent-feedback-input-widget');
+ this.domNode.style.display = 'none';
+
+ this.inputElement = document.createElement('textarea');
+ this.inputElement.rows = 1;
+ this.inputElement.placeholder = _options.placeholder;
+ this.inputElement.setAttribute('aria-label', _options.ariaLabel ?? _options.placeholder);
+ this.inputElement.style.lineHeight = `${FeedbackInputWidget._LINE_HEIGHT}px`;
+ this.domNode.appendChild(this.inputElement);
+
+ // Hidden element used to measure text width for auto-growing
+ this._measureElement = document.createElement('span');
+ this._measureElement.classList.add('agent-feedback-input-measure');
+ this.domNode.appendChild(this._measureElement);
+
+ const actionsContainer = document.createElement('div');
+ actionsContainer.classList.add('agent-feedback-input-actions');
+ actionsContainer.style.height = `${FeedbackInputWidget._LINE_HEIGHT}px`;
+ this.domNode.appendChild(actionsContainer);
+ this._actionsContainer = actionsContainer;
+
+ this._busyIndicator = document.createElement('div');
+ this._busyIndicator.classList.add('agent-feedback-input-busy-indicator', ...ThemeIcon.asClassNameArray(Codicon.loading), 'codicon-modifier-spin');
+ this._busyIndicator.setAttribute('aria-hidden', 'true');
+ this._busyIndicator.style.display = 'none';
+ this._busyIndicator.style.height = `${FeedbackInputWidget._LINE_HEIGHT}px`;
+ this.domNode.appendChild(this._busyIndicator);
+
+ this._primaryAction = this._register(new Action(
+ 'feedbackInput.primary',
+ _options.primaryAction.label,
+ ThemeIcon.asClassName(_options.primaryAction.icon),
+ false,
+ () => { this._onDidTriggerPrimary.fire(); return Promise.resolve(); }
+ ));
+
+ this._secondaryAction = _options.secondaryAction ? this._register(new Action(
+ 'feedbackInput.secondary',
+ _options.secondaryAction.label,
+ ThemeIcon.asClassName(_options.secondaryAction.icon),
+ false,
+ () => { this._onDidTriggerSecondary.fire(); return Promise.resolve(); }
+ )) : undefined;
+
+ this._actionBar = this._register(new ActionBar(actionsContainer));
+ this._actionBar.push(this._primaryAction, { icon: true, label: false, keybinding: _options.primaryAction.keybindingLabel });
+
+ if (this._secondaryAction) {
+ const modifierKeyEmitter = ModifierKeyEmitter.getInstance();
+ this._register(modifierKeyEmitter.event(status => this._updateActionForAlt(status.altKey)));
+ }
+
+ // Focus the input when clicking anywhere on the widget that isn't the
+ // textarea itself or the action bar (e.g. padding around the textarea).
+ this._register(addStandardDisposableListener(this.domNode, 'mousedown', e => {
+ const target = e.target as Node | null;
+ if (target === this.inputElement || actionsContainer.contains(target)) {
+ return;
+ }
+ e.preventDefault();
+ this.inputElement.focus();
+ }));
+ }
+
+ private _updateActionForAlt(altKey: boolean): void {
+ if (!this._secondaryAction) {
+ return;
+ }
+ if (altKey && !this._isShowingSecondary) {
+ this._isShowingSecondary = true;
+ this._actionBar.clear();
+ this._actionBar.push(this._secondaryAction, { icon: true, label: false, keybinding: this._options.secondaryAction!.keybindingLabel });
+ } else if (!altKey && this._isShowingSecondary) {
+ this._isShowingSecondary = false;
+ this._actionBar.clear();
+ this._actionBar.push(this._primaryAction, { icon: true, label: false, keybinding: this._options.primaryAction.keybindingLabel });
+ }
+ }
+
+ show(): void {
+ this.domNode.style.display = '';
+ }
+
+ hide(): void {
+ this.domNode.style.display = 'none';
+ }
+
+ clearInput(): void {
+ this.inputElement.value = '';
+ this.updateActionEnabled();
+ this.autoSize();
+ }
+
+ setPlaceholder(placeholder: string): void {
+ if (this.inputElement.placeholder === placeholder) {
+ return;
+ }
+ this.inputElement.placeholder = placeholder;
+ if (!this._hasExplicitAriaLabel) {
+ this.inputElement.setAttribute('aria-label', placeholder);
+ }
+ this.autoSize();
+ }
+
+ updateActionEnabled(): void {
+ const hasText = this.inputElement.value.trim().length > 0 && !this._busy;
+ this._primaryAction.enabled = hasText;
+ if (this._secondaryAction) {
+ this._secondaryAction.enabled = hasText;
+ }
+ }
+
+ /**
+ * Toggles an accessible busy state: disables the input/actions, swaps the
+ * action bar for a spinning loading codicon, and (when turning busy on)
+ * announces `statusLabel` to screen readers via `aria-busy` + a status
+ * announcement. Idempotent; a no-op call does not re-announce.
+ */
+ setBusy(busy: boolean, statusLabel?: string): void {
+ if (this._busy === busy) {
+ return;
+ }
+ this._busy = busy;
+ this.inputElement.disabled = busy;
+ this.inputElement.setAttribute('aria-busy', busy ? 'true' : 'false');
+ this._actionsContainer.style.display = busy ? 'none' : '';
+ this._busyIndicator.style.display = busy ? '' : 'none';
+ this.updateActionEnabled();
+ if (busy && statusLabel) {
+ announceStatus(statusLabel);
+ }
+ }
+
+ autoSize(): void {
+ const text = this.inputElement.value || this.inputElement.placeholder;
+
+ // Measure the text width using the hidden span
+ this._measureElement.textContent = text;
+ const textWidth = this._measureElement.scrollWidth;
+
+ // Clamp width between min and a max that never exceeds the host width.
+ // On very narrow hosts the max can drop below the nominal minimum, so
+ // derive an effective minimum that never exceeds the max and apply it
+ // inline to override the CSS `min-width` (otherwise the textarea would be
+ // forced back up to its CSS minimum and overflow the host).
+ const maxWidth = this._computeMaxWidth();
+ const minWidth = Math.min(FeedbackInputWidget._MIN_WIDTH, maxWidth);
+ const desiredWidth = Math.max(minWidth, textWidth + 10);
+ const width = Math.min(desiredWidth, maxWidth);
+ this.inputElement.style.minWidth = `${minWidth}px`;
+ this.inputElement.style.width = `${width}px`;
+
+ // Reset height to auto then expand to fit all content, with a minimum of 1 line
+ this.inputElement.style.height = 'auto';
+ const newHeight = Math.max(this.inputElement.scrollHeight, FeedbackInputWidget._LINE_HEIGHT);
+ this.inputElement.style.height = `${newHeight}px`;
+ }
+
+ private _computeMaxWidth(): number {
+ return Math.min(FeedbackInputWidget._MAX_WIDTH, this._options.getMaxContentWidth() * FeedbackInputWidget._MAX_WIDTH_HOST_FRACTION);
+ }
+}
diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css
index 197b6293224a89..25833c34b6a695 100644
--- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css
+++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css
@@ -75,6 +75,8 @@
.agent-feedback-input-widget .agent-feedback-input-actions {
display: flex;
align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
margin-left: 2px;
flex-shrink: 0;
}
@@ -85,6 +87,29 @@
height: 16px;
}
+.agent-feedback-input-widget textarea:disabled {
+ opacity: 0.6;
+ cursor: default;
+}
+
+.agent-feedback-input-widget .agent-feedback-input-busy-indicator {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ /* Same shared line-height box as .agent-feedback-input-actions, so the spinner replacing the send action centers identically to it. */
+ box-sizing: border-box;
+ margin-left: 2px;
+ flex-shrink: 0;
+ color: var(--vscode-icon-foreground);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .agent-feedback-input-widget .agent-feedback-input-busy-indicator {
+ animation: none;
+ }
+}
+
.monaco-editor .line-numbers.agent-feedback-glyph {
cursor: pointer;
z-index: 10;
diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/feedbackInputWidget.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/feedbackInputWidget.test.ts
new file mode 100644
index 00000000000000..638d83fc2eaee0
--- /dev/null
+++ b/src/vs/sessions/contrib/agentFeedback/test/browser/feedbackInputWidget.test.ts
@@ -0,0 +1,131 @@
+/*---------------------------------------------------------------------------------------------
+ * 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 { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js';
+import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
+import { Codicon } from '../../../../../base/common/codicons.js';
+import { FeedbackInputWidget } from '../../browser/feedbackInputWidget.js';
+
+suite('FeedbackInputWidget', () => {
+ const disposables = ensureNoDisposablesAreLeakedInTestSuite();
+
+ function createWidget(ariaLabel?: string) {
+ const store = disposables.add(new DisposableStore());
+ const widget = store.add(new FeedbackInputWidget({
+ placeholder: 'Ask Question',
+ ariaLabel,
+ getMaxContentWidth: () => 400,
+ primaryAction: { label: 'Ask', icon: Codicon.send, keybindingLabel: 'Enter' },
+ }));
+ return widget;
+ }
+
+ function actionsContainer(widget: FeedbackInputWidget): HTMLElement {
+ return widget.domNode.querySelector('.agent-feedback-input-actions')!;
+ }
+
+ function busyIndicator(widget: FeedbackInputWidget): HTMLElement {
+ return widget.domNode.querySelector('.agent-feedback-input-busy-indicator')!;
+ }
+
+ test('setBusy(true) disables the input, hides the action bar, and shows the spinner', () => {
+ const widget = createWidget();
+
+ widget.setBusy(true);
+
+ assert.strictEqual(widget.isBusy, true);
+ assert.strictEqual(widget.inputElement.disabled, true);
+ assert.strictEqual(widget.inputElement.getAttribute('aria-busy'), 'true');
+ assert.strictEqual(actionsContainer(widget).style.display, 'none');
+ assert.notStrictEqual(busyIndicator(widget).style.display, 'none');
+ });
+
+ test('setBusy(false) restores the input and action bar', () => {
+ const widget = createWidget();
+ widget.setBusy(true);
+
+ widget.setBusy(false);
+
+ assert.strictEqual(widget.isBusy, false);
+ assert.strictEqual(widget.inputElement.disabled, false);
+ assert.strictEqual(widget.inputElement.getAttribute('aria-busy'), 'false');
+ assert.strictEqual(actionsContainer(widget).style.display, '');
+ assert.strictEqual(busyIndicator(widget).style.display, 'none');
+ });
+
+ test('setBusy is idempotent and does not toggle disabled state again on a repeated call', () => {
+ const widget = createWidget();
+ widget.setBusy(true);
+ widget.inputElement.disabled = false; // simulate external state to detect a redundant re-apply
+
+ widget.setBusy(true);
+
+ assert.strictEqual(widget.inputElement.disabled, false, 'a no-op setBusy call must not re-apply state');
+ });
+
+ test('disables the primary action while busy even with text present', () => {
+ const widget = createWidget();
+ widget.inputElement.value = 'hello';
+ widget.updateActionEnabled();
+
+ widget.setBusy(true);
+
+ // Re-invoking updateActionEnabled (as callers do on 'input') must keep it disabled while busy.
+ widget.updateActionEnabled();
+ assert.strictEqual((widget as unknown as { _primaryAction: { enabled: boolean } })._primaryAction.enabled, false);
+ });
+
+ test('sizes the action bar and busy spinner to the shared single-line height for optical vertical centering', () => {
+ const widget = createWidget();
+ document.body.appendChild(widget.domNode);
+ disposables.add(toDisposable(() => widget.domNode.remove()));
+ widget.show();
+
+ const lineHeight = widget.inputElement.style.lineHeight;
+ assert.ok(lineHeight.length > 0, 'the input line height must be configured');
+
+ // Neither box is positioned via a one-off JS transform/top hack; alignment
+ // comes entirely from the shared flex/line-height CSS pattern below.
+ assert.strictEqual(actionsContainer(widget).style.transform, '');
+ assert.strictEqual(actionsContainer(widget).style.top, '');
+ assert.strictEqual(busyIndicator(widget).style.transform, '');
+ assert.strictEqual(busyIndicator(widget).style.top, '');
+
+ // Both boxes resolve to the shared line-height (not the smaller 16px icon size) so they center identically.
+ const win = document.defaultView!;
+ assert.strictEqual(win.getComputedStyle(actionsContainer(widget)).height, lineHeight);
+ const actionsRect = actionsContainer(widget).getBoundingClientRect();
+
+ widget.setBusy(true);
+ assert.strictEqual(win.getComputedStyle(busyIndicator(widget)).height, lineHeight);
+
+ // The spinner occupies the exact same box (same top and height) that the
+ // action bar it replaces did — the optical-centering fix reported as "the
+ // busy spinner is slightly too low" — not a smaller box flush to the bottom.
+ const busyRect = busyIndicator(widget).getBoundingClientRect();
+ assert.ok(actionsRect.height > 0, 'sanity: the action bar must have measured a non-zero height while visible');
+ assert.strictEqual(busyRect.top, actionsRect.top);
+ assert.strictEqual(busyRect.height, actionsRect.height);
+ });
+
+ test('setPlaceholder does not overwrite an explicitly configured aria-label', () => {
+ const widget = createWidget('Ask a question about the selected response text');
+
+ widget.setPlaceholder('New Placeholder');
+
+ assert.strictEqual(widget.inputElement.placeholder, 'New Placeholder');
+ assert.strictEqual(widget.inputElement.getAttribute('aria-label'), 'Ask a question about the selected response text');
+ });
+
+ test('setPlaceholder keeps the aria-label derived from the placeholder when none was configured', () => {
+ const widget = createWidget();
+ assert.strictEqual(widget.inputElement.getAttribute('aria-label'), 'Ask Question');
+
+ widget.setPlaceholder('New Placeholder');
+
+ assert.strictEqual(widget.inputElement.getAttribute('aria-label'), 'New Placeholder');
+ });
+});
diff --git a/src/vs/sessions/contrib/chat/browser/btwSlashCommand.contribution.ts b/src/vs/sessions/contrib/chat/browser/btwSlashCommand.contribution.ts
index afb0507a23650f..3e3d2156a5bcd7 100644
--- a/src/vs/sessions/contrib/chat/browser/btwSlashCommand.contribution.ts
+++ b/src/vs/sessions/contrib/chat/browser/btwSlashCommand.contribution.ts
@@ -20,6 +20,7 @@ import { ISessionsService } from '../../../services/sessions/browser/sessionsSer
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
import { SessionIsArchivedContext, SessionIsCreatedContext, SessionSupportsSideChatContext } from '../../../common/contextkeys.js';
import { ISideChatSelection, SessionStatus } from '../../../services/sessions/common/session.js';
+import { openAndSendSideChat } from './sideChatOrchestration.js';
function captureSideChatSelection(widget: IChatWidgetService['lastFocusedWidget']): ISideChatSelection | undefined {
if (!widget) {
@@ -109,8 +110,7 @@ export class BtwSlashCommandContribution extends Disposable implements IWorkbenc
return;
}
- await sessionsService.openChat(session, sideChat.resource);
- await sessionsManagementService.sendRequest(session, sideChat, { query: remainder });
+ await openAndSendSideChat(sessionsManagementService, sessionsService, session, sideChat, remainder);
}));
}
}
diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts
index 350997f51a1d5b..2a22c7eccf9957 100644
--- a/src/vs/sessions/contrib/chat/browser/chatView.ts
+++ b/src/vs/sessions/contrib/chat/browser/chatView.ts
@@ -32,6 +32,7 @@ import { NewChatWidget } from './newChatWidget.js';
import { NewChatInSessionWidget } from './newChatInSessionWidget.js';
import { SessionInputBanners } from '../../sessionInputBanners/browser/sessionInputBanners.js';
import { SessionChatInputToolbar } from './sessionChatInputToolbar.js';
+import { ResponseSelectionSideChatController } from './responseSelectionSideChatController.js';
import { ISessionChatPillsDebugService } from './sessionChatInputToolbarDebug.js';
import { AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING } from './sessionsChatHistory.js';
import { activeSessionViewBackground, activeSessionViewForeground, agentsPanelBackground, inactiveSessionViewBackground, inactiveSessionViewForeground } from '../../../common/theme.js';
@@ -118,6 +119,9 @@ export class ChatView extends AbstractChatView {
/** Floating status pills (changes, preview, background activity) above the input. */
private readonly _chatPills: SessionChatInputToolbar;
+ /** Shows an "Ask Question" input when the user selects assistant markdown text. */
+ private readonly _selectionSideChatController: ResponseSelectionSideChatController;
+
/** Reference to the loaded chat model; disposing releases the model. */
private readonly _modelRef = this._register(new MutableDisposable());
@@ -190,6 +194,8 @@ export class ChatView extends AbstractChatView {
this._widget.render(this.element);
this._widget.setVisible(true);
+ this._selectionSideChatController = this._register(scopedInstantiationService.createInstance(ResponseSelectionSideChatController, this._widget));
+
// Mount the session banners directly above the chat input.
this._banners = this._register(instantiationService.createInstance(SessionInputBanners));
this._banners.setActive(this._isActive);
@@ -245,6 +251,7 @@ export class ChatView extends AbstractChatView {
// Reflect this chat's last-turn changes, status, and background activity.
this._chatPills.setChat(chat);
+ this._selectionSideChatController.setChat(chat);
this._banners.setDebugData(undefined);
// Reflect read-only (non-interactive) chats: hide the composer and gate
diff --git a/src/vs/sessions/contrib/chat/browser/responseSelectionResolver.ts b/src/vs/sessions/contrib/chat/browser/responseSelectionResolver.ts
new file mode 100644
index 00000000000000..6176962b922834
--- /dev/null
+++ b/src/vs/sessions/contrib/chat/browser/responseSelectionResolver.ts
@@ -0,0 +1,65 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import * as dom from '../../../../base/browser/dom.js';
+import { IChatWidget } from '../../../../workbench/contrib/chat/browser/chat.js';
+import { IChatResponseViewModel, isResponseVM } from '../../../../workbench/contrib/chat/common/model/chatViewModel.js';
+
+export interface IResolvedResponseSelection {
+ readonly response: IChatResponseViewModel;
+ readonly text: string;
+}
+
+/** Ancestor of a valid selection endpoint: rendered assistant markdown. */
+const markdownScopeSelector = '.chat-markdown-part';
+/** Ancestors that exclude an endpoint even inside markdown (embedded code editors, tool UI). */
+const excludedAncestorSelectors = ['.monaco-editor', '.chat-tool-invocation-part'];
+
+function closestElement(node: Node): HTMLElement | undefined {
+ return node.nodeType === Node.ELEMENT_NODE ? node as HTMLElement : node.parentElement ?? undefined;
+}
+
+function isAssistantMarkdownEndpoint(node: Node, widgetDomNode: HTMLElement): boolean {
+ const element = closestElement(node);
+ if (!element || !widgetDomNode.contains(element) || !element.closest(markdownScopeSelector)) {
+ return false;
+ }
+ return !excludedAncestorSelectors.some(selector => element.closest(selector));
+}
+
+/**
+ * Resolves the widget's current native DOM selection to the single assistant
+ * response it lies entirely within, scoped to rendered markdown only (embedded
+ * code editors and tool-invocation UI are excluded). Returns `undefined` for an
+ * empty/collapsed selection, a selection spanning more than one response, or
+ * one that touches non-markdown content.
+ */
+export function resolveResponseSelection(widget: IChatWidget): IResolvedResponseSelection | undefined {
+ const nativeSelection = dom.getWindow(widget.domNode).getSelection();
+ const text = nativeSelection?.toString();
+ if (!nativeSelection || nativeSelection.isCollapsed || !text?.trim()) {
+ return undefined;
+ }
+
+ const { anchorNode, focusNode } = nativeSelection;
+ if (!anchorNode || !focusNode
+ || !isAssistantMarkdownEndpoint(anchorNode, widget.domNode)
+ || !isAssistantMarkdownEndpoint(focusNode, widget.domNode)) {
+ return undefined;
+ }
+
+ const anchorElement = closestElement(anchorNode);
+ const focusElement = closestElement(focusNode);
+ if (!anchorElement || !focusElement) {
+ return undefined;
+ }
+ const anchorItem = widget.getElementFromNode(anchorElement);
+ const focusItem = widget.getElementFromNode(focusElement);
+ if (!anchorItem || anchorItem !== focusItem || !isResponseVM(anchorItem)) {
+ return undefined;
+ }
+
+ return { response: anchorItem, text };
+}
diff --git a/src/vs/sessions/contrib/chat/browser/responseSelectionSideChatController.ts b/src/vs/sessions/contrib/chat/browser/responseSelectionSideChatController.ts
new file mode 100644
index 00000000000000..793e241f9d0df3
--- /dev/null
+++ b/src/vs/sessions/contrib/chat/browser/responseSelectionSideChatController.ts
@@ -0,0 +1,239 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import * as dom from '../../../../base/browser/dom.js';
+import { Disposable } from '../../../../base/common/lifecycle.js';
+import { Codicon } from '../../../../base/common/codicons.js';
+import { KeyCode } from '../../../../base/common/keyCodes.js';
+import { localize } from '../../../../nls.js';
+import { ILogService } from '../../../../platform/log/common/log.js';
+import { INotificationService } from '../../../../platform/notification/common/notification.js';
+import { IChatWidget } from '../../../../workbench/contrib/chat/browser/chat.js';
+import { FeedbackInputWidget } from '../../agentFeedback/browser/feedbackInputWidget.js';
+import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js';
+import { IChat, SessionStatus } from '../../../services/sessions/common/session.js';
+import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
+import { IResolvedResponseSelection, resolveResponseSelection } from './responseSelectionResolver.js';
+import { createAndSendSideChat } from './sideChatOrchestration.js';
+
+/**
+ * Agents-window-only controller that shows an "Ask Question" input (reusing
+ * {@link FeedbackInputWidget}) when the user selects text within a single
+ * assistant response's rendered markdown, and creates a side chat anchored to
+ * that response when submitted. Owned by `ChatView` so this affordance never
+ * appears in the regular workbench chat surface.
+ */
+export class ResponseSelectionSideChatController extends Disposable {
+
+ private readonly _input: FeedbackInputWidget;
+ private _resolved: IResolvedResponseSelection | undefined;
+ private _chat: IChat | undefined;
+ /** Bumped on a genuine chat navigation/force-dismiss so a stale submission's completion/error handler can no-op. */
+ private _generation = 0;
+
+ constructor(
+ private readonly _widget: IChatWidget,
+ @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,
+ @ISessionsService private readonly _sessionsService: ISessionsService,
+ @ILogService private readonly _logService: ILogService,
+ @INotificationService private readonly _notificationService: INotificationService,
+ ) {
+ super();
+
+ this._input = this._register(new FeedbackInputWidget({
+ placeholder: localize('sessions.selectionSideChat.placeholder', "Ask Question"),
+ ariaLabel: localize('sessions.selectionSideChat.ariaLabel', "Ask a question about the selected response text"),
+ getMaxContentWidth: () => this._widget.domNode.clientWidth,
+ primaryAction: {
+ label: localize('sessions.selectionSideChat.ask', "Ask Question"),
+ icon: Codicon.send,
+ keybindingLabel: localize('sessions.selectionSideChat.enter', "Enter"),
+ },
+ }));
+ this._widget.domNode.appendChild(this._input.domNode);
+
+ this._register(this._input.onDidTriggerPrimary(() => this._submit()));
+ this._register(dom.addStandardDisposableListener(this._input.inputElement, 'keydown', e => {
+ if (e.keyCode === KeyCode.Escape) {
+ e.preventDefault();
+ e.stopPropagation();
+ this._dismiss();
+ return;
+ }
+ if (e.keyCode === KeyCode.Enter) {
+ if (e.browserEvent.isComposing || e.shiftKey) {
+ // Let IME composition finish, or Shift+Enter insert a newline.
+ return;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ this._submit();
+ }
+ }));
+ this._register(dom.addStandardDisposableListener(this._input.inputElement, 'keypress', e => {
+ e.stopPropagation();
+ }));
+ this._register(dom.addStandardDisposableListener(this._input.inputElement, 'input', () => {
+ this._input.autoSize();
+ this._input.updateActionEnabled();
+ }));
+
+ const window = dom.getWindow(this._widget.domNode);
+ this._register(dom.addDisposableListener(window.document, 'selectionchange', () => this._onSelectionChange()));
+ // Scrolling the transcript invalidates the widget's pinned position; hide rather than drift.
+ this._register(dom.addDisposableListener(this._widget.domNode, 'scroll', () => this._dismiss(), true));
+ }
+
+ /**
+ * Tracks which chat the current transcript belongs to, for side-chat
+ * creation. `ChatView` re-invokes this for the same chat on unrelated
+ * observable changes, so only force-dismiss on a genuine resource change.
+ */
+ setChat(chat: IChat): void {
+ const changedChat = !this._chat || this._chat.resource.toString() !== chat.resource.toString();
+ this._chat = chat;
+ if (changedChat) {
+ this._dismiss(true);
+ }
+ }
+
+ private _onSelectionChange(): void {
+ // The browser collapses the document selection the moment the "Ask
+ // Question" textarea receives focus (textareas don't participate in
+ // the Selection API). Ignore selectionchange entirely while focus is
+ // inside the input so typing doesn't dismiss the widget it just
+ // captured; a real outside invalidation is handled once focus
+ // actually leaves (the next selectionchange runs with focus outside).
+ if (dom.isAncestorOfActiveElement(this._input.domNode)) {
+ return;
+ }
+ // A pending submission owns the overlay until the view changes (see
+ // `_dismiss`); don't let an incidental selection change reposition or
+ // swap the captured selection out from under it.
+ if (this._input.isBusy) {
+ return;
+ }
+ const resolved = resolveResponseSelection(this._widget);
+ if (!resolved) {
+ this._dismiss();
+ return;
+ }
+ this._resolved = resolved;
+ this._showFor(resolved);
+ }
+
+ private _showFor(resolved: IResolvedResponseSelection): void {
+ const nativeSelection = dom.getWindow(this._widget.domNode).getSelection();
+ const range = nativeSelection?.rangeCount ? nativeSelection.getRangeAt(0) : undefined;
+ if (!range) {
+ return;
+ }
+ const selectionRect = range.getBoundingClientRect();
+ const containerRect = this._widget.domNode.getBoundingClientRect();
+
+ this._input.show();
+ this._input.autoSize();
+ this._input.updateActionEnabled();
+
+ const gap = 4;
+ const inputWidth = this._input.domNode.offsetWidth;
+ const inputHeight = this._input.domNode.offsetHeight;
+ const viewport = dom.getWindow(this._widget.domNode);
+
+ const maxLeft = Math.max(0, containerRect.width - inputWidth);
+ const left = Math.max(0, Math.min(selectionRect.left - containerRect.left, maxLeft));
+
+ // Clamp to whichever is smaller: the widget's own box, or the visible
+ // viewport below the widget's top edge, so the popup never renders
+ // past either bound.
+ const maxTop = Math.max(0, Math.min(containerRect.height, viewport.innerHeight - containerRect.top) - inputHeight);
+ let top = selectionRect.bottom - containerRect.top + gap;
+ if (top > maxTop) {
+ // Not enough room below the selection: prefer placing it above instead.
+ const aboveTop = selectionRect.top - containerRect.top - inputHeight - gap;
+ top = aboveTop >= 0 ? aboveTop : maxTop;
+ }
+ top = Math.max(0, Math.min(top, maxTop));
+
+ this._input.domNode.style.top = `${top}px`;
+ this._input.domNode.style.left = `${left}px`;
+ }
+
+ /**
+ * Dismisses the input. While a submission is pending (`_input.isBusy`),
+ * only a genuine view change (`force`, from {@link setChat}) may dismiss
+ * it — outside interactions like Escape, scrolling, or selection
+ * invalidation must not race the in-flight create/open/send.
+ */
+ private _dismiss(force = false): void {
+ if (!force && this._input.isBusy) {
+ return;
+ }
+ if (force) {
+ // A genuine navigation: bump the generation so a stale submission's completion/error handler no-ops.
+ this._generation++;
+ }
+ const hadFocus = dom.isAncestorOfActiveElement(this._input.domNode);
+ this._resolved = undefined;
+ this._input.setBusy(false);
+ this._input.hide();
+ this._input.clearInput();
+ if (hadFocus) {
+ // Hiding the focused input would otherwise leave focus stranded on
+ // the body; return it to the transcript it was invoked from.
+ this._widget.focusResponseItem(true);
+ }
+ }
+
+ private _submit(): void {
+ const resolved = this._resolved;
+ const chat = this._chat;
+ const query = this._input.inputElement.value.trim();
+ if (!resolved || !chat || !query || this._input.isBusy) {
+ return;
+ }
+
+ const found = this._sessionsManagementService.getSessionForChatResource(chat.resource);
+ if (!found) {
+ this._notificationService.warn(localize('sessions.selectionSideChat.sessionUnavailable', "A side chat cannot be created from this conversation."));
+ return;
+ }
+ const { session } = found;
+ if (session.status.get() === SessionStatus.Untitled || session.isArchived.get() || !session.capabilities.get().supportsSideChat) {
+ this._notificationService.warn(localize('sessions.selectionSideChat.unsupported', "This conversation does not support side chats."));
+ return;
+ }
+
+ // Keep the overlay visible with a busy state instead of eagerly
+ // dismissing: opening the created side chat naturally dismisses it via
+ // `setChat`; on failure the question and normal controls are restored
+ // below so the user can retry.
+ this._input.setBusy(true, localize('sessions.selectionSideChat.busy', "Asking question…"));
+ const generation = this._generation;
+ createAndSendSideChat(this._sessionsManagementService, this._sessionsService, session, chat.resource, resolved.response.requestId, query, { text: resolved.text })
+ .then(() => {
+ // A stale completion after a genuine navigation force-dismissed this overlay must no-op.
+ if (this._generation !== generation) {
+ return;
+ }
+ // `setChat` (fired by the view change from opening the side
+ // chat) normally dismisses this overlay already; clear busy
+ // defensively in case that doesn't happen.
+ this._input.setBusy(false);
+ })
+ .catch(err => {
+ this._logService.error('[selectionSideChat] Failed to create side chat', err);
+ if (this._generation !== generation) {
+ return;
+ }
+ this._notificationService.error(localize('sessions.selectionSideChat.createFailed', "The side chat could not be created."));
+ this._input.setBusy(false);
+ this._input.inputElement.value = query;
+ this._input.autoSize();
+ this._input.updateActionEnabled();
+ this._input.inputElement.focus();
+ });
+ }
+}
diff --git a/src/vs/sessions/contrib/chat/browser/sideChatOrchestration.ts b/src/vs/sessions/contrib/chat/browser/sideChatOrchestration.ts
new file mode 100644
index 00000000000000..a613883c2254c0
--- /dev/null
+++ b/src/vs/sessions/contrib/chat/browser/sideChatOrchestration.ts
@@ -0,0 +1,44 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { URI } from '../../../../base/common/uri.js';
+import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js';
+import { IChat, ISession, ISideChatSelection } from '../../../services/sessions/common/session.js';
+import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
+
+/**
+ * Activates `sideChat` through the normal sessions navigation flow, then
+ * sends `query` on it. Shared by every side-chat entry point (`/btw`,
+ * response-selection) so they stay consistent about activating the chat
+ * before sending the first message.
+ */
+export async function openAndSendSideChat(
+ sessionsManagementService: ISessionsManagementService,
+ sessionsService: ISessionsService,
+ session: ISession,
+ sideChat: IChat,
+ query: string,
+): Promise {
+ await sessionsService.openChat(session, sideChat.resource);
+ await sessionsManagementService.sendRequest(session, sideChat, { query });
+}
+
+/**
+ * Creates a side chat branched from `turnId` in `sourceChat`, then opens and
+ * sends `query` on it via {@link openAndSendSideChat}.
+ */
+export async function createAndSendSideChat(
+ sessionsManagementService: ISessionsManagementService,
+ sessionsService: ISessionsService,
+ session: ISession,
+ sourceChat: URI,
+ turnId: string,
+ query: string,
+ selection?: ISideChatSelection,
+): Promise {
+ const sideChat = await sessionsManagementService.createSideChatInSession(session, sourceChat, turnId, selection);
+ await openAndSendSideChat(sessionsManagementService, sessionsService, session, sideChat, query);
+ return sideChat;
+}
diff --git a/src/vs/sessions/contrib/chat/test/browser/responseSelectionResolver.test.ts b/src/vs/sessions/contrib/chat/test/browser/responseSelectionResolver.test.ts
new file mode 100644
index 00000000000000..fa5ab6ff75431f
--- /dev/null
+++ b/src/vs/sessions/contrib/chat/test/browser/responseSelectionResolver.test.ts
@@ -0,0 +1,163 @@
+/*---------------------------------------------------------------------------------------------
+ * 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 * as dom from '../../../../../base/browser/dom.js';
+import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js';
+import { upcastPartial } from '../../../../../base/test/common/mock.js';
+import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
+import { IChatWidget } from '../../../../../workbench/contrib/chat/browser/chat.js';
+import { IChatResponseViewModel } from '../../../../../workbench/contrib/chat/common/model/chatViewModel.js';
+import { resolveResponseSelection } from '../../browser/responseSelectionResolver.js';
+
+function makeResponse(requestId: string): IChatResponseViewModel {
+ return upcastPartial({ requestId, setVote: () => undefined });
+}
+
+function stubSelection(store: DisposableStore, anchorNode: Node, focusNode: Node, text: string): void {
+ const targetWindow = dom.getWindow(anchorNode);
+ const original = targetWindow.getSelection.bind(targetWindow);
+ const mutableWindow = targetWindow as typeof targetWindow & { getSelection: () => Selection | null };
+ mutableWindow.getSelection = () => ({
+ toString: () => text,
+ isCollapsed: text.length === 0,
+ anchorNode,
+ focusNode,
+ rangeCount: 1,
+ } as Selection);
+ store.add(toDisposable(() => { mutableWindow.getSelection = original; }));
+}
+
+suite('resolveResponseSelection', () => {
+ const disposables = ensureNoDisposablesAreLeakedInTestSuite();
+
+ function setup() {
+ const store = disposables.add(new DisposableStore());
+ const doc = dom.getActiveDocument();
+ const widgetDomNode = doc.createElement('div');
+ doc.body.appendChild(widgetDomNode);
+ store.add(toDisposable(() => widgetDomNode.remove()));
+ return { store, doc, widgetDomNode };
+ }
+
+ test('resolves a plain markdown selection within a single response', () => {
+ const { store, doc, widgetDomNode } = setup();
+ const markdown = doc.createElement('div');
+ markdown.classList.add('chat-markdown-part');
+ const textNode = doc.createTextNode('hello world');
+ markdown.appendChild(textNode);
+ widgetDomNode.appendChild(markdown);
+
+ const response = makeResponse('turn-1');
+ stubSelection(store, textNode, textNode, 'hello world');
+ const widget = upcastPartial({
+ domNode: widgetDomNode,
+ getElementFromNode: () => response,
+ });
+
+ const resolved = resolveResponseSelection(widget);
+ assert.ok(resolved);
+ assert.strictEqual(resolved!.response, response);
+ assert.strictEqual(resolved!.text, 'hello world');
+ });
+
+ test('rejects a collapsed (empty) selection', () => {
+ const { store, doc, widgetDomNode } = setup();
+ const markdown = doc.createElement('div');
+ markdown.classList.add('chat-markdown-part');
+ const textNode = doc.createTextNode('hello world');
+ markdown.appendChild(textNode);
+ widgetDomNode.appendChild(markdown);
+
+ stubSelection(store, textNode, textNode, '');
+ const widget = upcastPartial({
+ domNode: widgetDomNode,
+ getElementFromNode: () => makeResponse('turn-1'),
+ });
+
+ assert.strictEqual(resolveResponseSelection(widget), undefined);
+ });
+
+ test('rejects a selection inside an embedded code editor', () => {
+ const { store, doc, widgetDomNode } = setup();
+ const markdown = doc.createElement('div');
+ markdown.classList.add('chat-markdown-part');
+ const editor = doc.createElement('div');
+ editor.classList.add('monaco-editor');
+ const textNode = doc.createTextNode('const x = 1;');
+ editor.appendChild(textNode);
+ markdown.appendChild(editor);
+ widgetDomNode.appendChild(markdown);
+
+ stubSelection(store, textNode, textNode, 'const x = 1;');
+ const widget = upcastPartial({
+ domNode: widgetDomNode,
+ getElementFromNode: () => makeResponse('turn-1'),
+ });
+
+ assert.strictEqual(resolveResponseSelection(widget), undefined);
+ });
+
+ test('rejects a selection inside tool-invocation UI', () => {
+ const { store, doc, widgetDomNode } = setup();
+ const markdown = doc.createElement('div');
+ markdown.classList.add('chat-markdown-part');
+ const tool = doc.createElement('div');
+ tool.classList.add('chat-tool-invocation-part');
+ const textNode = doc.createTextNode('ran a tool');
+ tool.appendChild(textNode);
+ markdown.appendChild(tool);
+ widgetDomNode.appendChild(markdown);
+
+ stubSelection(store, textNode, textNode, 'ran a tool');
+ const widget = upcastPartial({
+ domNode: widgetDomNode,
+ getElementFromNode: () => makeResponse('turn-1'),
+ });
+
+ assert.strictEqual(resolveResponseSelection(widget), undefined);
+ });
+
+ test('rejects a selection spanning two different responses', () => {
+ const { store, doc, widgetDomNode } = setup();
+ const markdown = doc.createElement('div');
+ markdown.classList.add('chat-markdown-part');
+ const partA = doc.createElement('span');
+ const partB = doc.createElement('span');
+ const textNode1 = doc.createTextNode('first response');
+ const textNode2 = doc.createTextNode('second response');
+ partA.appendChild(textNode1);
+ partB.appendChild(textNode2);
+ markdown.appendChild(partA);
+ markdown.appendChild(partB);
+ widgetDomNode.appendChild(markdown);
+
+ stubSelection(store, textNode1, textNode2, 'first response second response');
+ const responseA = makeResponse('turn-1');
+ const responseB = makeResponse('turn-2');
+ const widget = upcastPartial({
+ domNode: widgetDomNode,
+ getElementFromNode: (node: HTMLElement) => node === partA ? responseA : responseB,
+ });
+
+ assert.strictEqual(resolveResponseSelection(widget), undefined);
+ });
+
+ test('rejects a selection outside the markdown scope', () => {
+ const { store, doc, widgetDomNode } = setup();
+ const other = doc.createElement('div');
+ const textNode = doc.createTextNode('not markdown');
+ other.appendChild(textNode);
+ widgetDomNode.appendChild(other);
+
+ stubSelection(store, textNode, textNode, 'not markdown');
+ const widget = upcastPartial({
+ domNode: widgetDomNode,
+ getElementFromNode: () => makeResponse('turn-1'),
+ });
+
+ assert.strictEqual(resolveResponseSelection(widget), undefined);
+ });
+});
diff --git a/src/vs/sessions/contrib/chat/test/browser/responseSelectionSideChatController.test.ts b/src/vs/sessions/contrib/chat/test/browser/responseSelectionSideChatController.test.ts
new file mode 100644
index 00000000000000..df5fb9cdcfdecd
--- /dev/null
+++ b/src/vs/sessions/contrib/chat/test/browser/responseSelectionSideChatController.test.ts
@@ -0,0 +1,508 @@
+/*---------------------------------------------------------------------------------------------
+ * 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 * as dom from '../../../../../base/browser/dom.js';
+import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js';
+import { constObservable } from '../../../../../base/common/observable.js';
+import { URI } from '../../../../../base/common/uri.js';
+import { upcastPartial } from '../../../../../base/test/common/mock.js';
+import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
+import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js';
+import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
+import { INotificationHandle, INotificationService, Severity } from '../../../../../platform/notification/common/notification.js';
+import { TestNotificationService } from '../../../../../platform/notification/test/common/testNotificationService.js';
+import { IChatWidget } from '../../../../../workbench/contrib/chat/browser/chat.js';
+import { IChatResponseViewModel } from '../../../../../workbench/contrib/chat/common/model/chatViewModel.js';
+import { ResponseSelectionSideChatController } from '../../browser/responseSelectionSideChatController.js';
+import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js';
+import { IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js';
+import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';
+
+class RecordingNotificationService extends TestNotificationService {
+ readonly notifications: { severity: Severity; message: string }[] = [];
+ override warn(message: string): INotificationHandle {
+ this.notifications.push({ severity: Severity.Warning, message });
+ return super.warn(message);
+ }
+ override error(error: string | Error): INotificationHandle {
+ this.notifications.push({ severity: Severity.Error, message: error instanceof Error ? error.message : error });
+ return super.error(error);
+ }
+}
+
+suite('ResponseSelectionSideChatController', () => {
+ const disposables = ensureNoDisposablesAreLeakedInTestSuite();
+
+ function setup(options?: {
+ createSideChatInSession?: ISessionsManagementService['createSideChatInSession'];
+ sendRequest?: ISessionsManagementService['sendRequest'];
+ }) {
+ const store = disposables.add(new DisposableStore());
+ const instantiationService = store.add(new TestInstantiationService());
+ const doc = dom.getActiveDocument();
+ const widgetDomNode = doc.createElement('div');
+ doc.body.appendChild(widgetDomNode);
+ store.add(toDisposable(() => widgetDomNode.remove()));
+
+ const markdown = doc.createElement('div');
+ markdown.classList.add('chat-markdown-part');
+ const textNode = doc.createTextNode('hello world');
+ markdown.appendChild(textNode);
+ widgetDomNode.appendChild(markdown);
+
+ const response = upcastPartial({ requestId: 'turn-1', setVote: () => undefined });
+ const focusResponseItemCalls: boolean[] = [];
+ const widget = upcastPartial({
+ domNode: widgetDomNode,
+ getElementFromNode: () => response,
+ focusResponseItem: (lastFocused?: boolean) => { focusResponseItemCalls.push(!!lastFocused); },
+ });
+
+ let containerRect: Partial = { top: 0, left: 0, width: 600, height: 600 };
+ widgetDomNode.getBoundingClientRect = () => containerRect as DOMRect;
+
+ const targetWindow = dom.getWindow(widgetDomNode);
+ const originalGetSelection = targetWindow.getSelection.bind(targetWindow);
+ const mutableWindow = targetWindow as typeof targetWindow & { getSelection: () => Selection | null };
+ let selectionText = '';
+ let rangeRect: Partial = { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 };
+ mutableWindow.getSelection = () => ({
+ toString: () => selectionText,
+ isCollapsed: selectionText.length === 0,
+ anchorNode: textNode,
+ focusNode: textNode,
+ rangeCount: 1,
+ getRangeAt: () => ({
+ getBoundingClientRect: () => rangeRect as DOMRect,
+ }),
+ } as unknown as Selection);
+ store.add(toDisposable(() => { mutableWindow.getSelection = originalGetSelection; }));
+
+ const setSelection = (text: string, rect?: Partial) => {
+ selectionText = text;
+ if (rect) {
+ rangeRect = rect;
+ }
+ doc.dispatchEvent(new Event('selectionchange'));
+ };
+ const setContainerRect = (rect: Partial) => { containerRect = rect; };
+
+ const sideChat = upcastPartial({ resource: URI.parse('test:///chat/side') });
+ const chat = upcastPartial({ resource: URI.parse('test:///chat/source') });
+ const session = upcastPartial({
+ sessionId: 'session',
+ resource: URI.parse('test:///session'),
+ status: constObservable(SessionStatus.Completed),
+ isArchived: constObservable(false),
+ capabilities: constObservable({ supportsMultipleChats: true, supportsSideChat: true }),
+ });
+
+ const callOrder: string[] = [];
+ const notificationService = new RecordingNotificationService();
+ instantiationService.stub(ISessionsManagementService, upcastPartial({
+ getSessionForChatResource: resource => resource.toString() === chat.resource.toString() ? { session, chat } : undefined,
+ createSideChatInSession: options?.createSideChatInSession ?? (async (_session, _sourceChat, turnId, selection) => {
+ callOrder.push(`create:${turnId}:${selection?.text}`);
+ return sideChat;
+ }),
+ sendRequest: options?.sendRequest ?? (async (_session, sentChat, sendOptions) => {
+ callOrder.push(`send:${sentChat.resource.toString()}:${sendOptions.query}`);
+ }),
+ }));
+ instantiationService.stub(ISessionsService, upcastPartial({
+ openChat: async (_session, chatUri) => {
+ callOrder.push(`open:${chatUri.toString()}`);
+ },
+ }));
+ instantiationService.stub(INotificationService, notificationService);
+ instantiationService.stub(ILogService, new NullLogService());
+
+ const controller = store.add(instantiationService.createInstance(ResponseSelectionSideChatController, widget));
+ controller.setChat(chat);
+
+ return { controller, setSelection, setContainerRect, callOrder, doc, chat, sideChat, focusResponseItemCalls, notificationService };
+ }
+
+ function inputDomNode(controller: ResponseSelectionSideChatController): HTMLElement {
+ return (controller as unknown as { _input: { domNode: HTMLElement } })._input.domNode;
+ }
+
+ function inputTextArea(controller: ResponseSelectionSideChatController): HTMLTextAreaElement {
+ return (controller as unknown as { _input: { inputElement: HTMLTextAreaElement } })._input.inputElement;
+ }
+
+ function isInputBusy(controller: ResponseSelectionSideChatController): boolean {
+ return (controller as unknown as { _input: { isBusy: boolean } })._input.isBusy;
+ }
+
+ function submitViaClick(controller: ResponseSelectionSideChatController, query: string): void {
+ const textArea = inputTextArea(controller);
+ textArea.value = query;
+ textArea.dispatchEvent(new Event('input', { bubbles: true }));
+ inputDomNode(controller).querySelector('.action-label')!.click();
+ }
+
+ function dispatchKey(target: HTMLElement, key: string, options?: { shiftKey?: boolean; isComposing?: boolean }): KeyboardEvent {
+ const event = new KeyboardEvent('keydown', { key, shiftKey: options?.shiftKey, bubbles: true, cancelable: true });
+ Object.defineProperty(event, 'keyCode', { get: () => key === 'Escape' ? 27 : 13 });
+ if (options?.isComposing) {
+ Object.defineProperty(event, 'isComposing', { get: () => true });
+ }
+ target.dispatchEvent(event);
+ return event;
+ }
+
+ test('shows the ask-question input for a valid markdown selection', () => {
+ const { controller, setSelection } = setup();
+ assert.strictEqual(inputDomNode(controller).style.display, 'none');
+
+ setSelection('hello world');
+ assert.notStrictEqual(inputDomNode(controller).style.display, 'none');
+ });
+
+ test('hides the input again once the selection is cleared', () => {
+ const { controller, setSelection } = setup();
+ setSelection('hello world');
+ assert.notStrictEqual(inputDomNode(controller).style.display, 'none');
+
+ setSelection('');
+ assert.strictEqual(inputDomNode(controller).style.display, 'none');
+ });
+
+ test('creates, opens, and sends a side chat anchored to the response on submit', async () => {
+ const { controller, setSelection, callOrder, sideChat } = setup();
+ setSelection('hello world');
+
+ const textArea = inputTextArea(controller);
+ textArea.value = 'what does this mean?';
+ textArea.dispatchEvent(new Event('input', { bubbles: true }));
+ inputDomNode(controller).querySelector('.action-label')!.click();
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ assert.deepStrictEqual(callOrder, [
+ 'create:turn-1:hello world',
+ `open:${sideChat.resource.toString()}`,
+ `send:${sideChat.resource.toString()}:what does this mean?`,
+ ]);
+ });
+
+ test('stays visible and keeps the captured selection when the input steals focus and collapses the native selection', () => {
+ const { controller, setSelection, callOrder } = setup();
+ setSelection('hello world');
+ assert.notStrictEqual(inputDomNode(controller).style.display, 'none');
+
+ const textArea = inputTextArea(controller);
+ textArea.focus();
+ // Focusing the textarea collapses the document Selection as a browser
+ // side effect; this must not dismiss the widget.
+ setSelection('');
+ assert.notStrictEqual(inputDomNode(controller).style.display, 'none', 'input must stay visible while focused');
+
+ // The originally-captured selection must still be used on submit, not
+ // the now-empty native selection.
+ textArea.value = 'what does this mean?';
+ textArea.dispatchEvent(new Event('input', { bubbles: true }));
+ inputDomNode(controller).querySelector('.action-label')!.click();
+ assert.ok(callOrder[0]?.startsWith('create:turn-1:hello world'));
+ });
+
+ test('dismisses once focus genuinely leaves the input and the selection is invalid', () => {
+ const { controller, setSelection } = setup();
+ setSelection('hello world');
+
+ const textArea = inputTextArea(controller);
+ textArea.focus();
+ setSelection('');
+ assert.notStrictEqual(inputDomNode(controller).style.display, 'none');
+
+ textArea.blur();
+ setSelection('');
+ assert.strictEqual(inputDomNode(controller).style.display, 'none', 'input must dismiss once focus truly leaves it');
+ });
+
+ test('restores focus to the response item when Escape dismisses the focused input', () => {
+ const { controller, setSelection, focusResponseItemCalls } = setup();
+ setSelection('hello world');
+
+ const textArea = inputTextArea(controller);
+ textArea.focus();
+ dispatchKey(textArea, 'Escape');
+
+ assert.strictEqual(inputDomNode(controller).style.display, 'none');
+ assert.deepStrictEqual(focusResponseItemCalls, [true]);
+ });
+
+ test('does not restore focus on dismiss when the input was not focused', () => {
+ const { setSelection, focusResponseItemCalls } = setup();
+ setSelection('hello world');
+ setSelection('');
+
+ assert.deepStrictEqual(focusResponseItemCalls, []);
+ });
+
+ test('clamps the overlay vertically within the container bounds when the selection is near the bottom', () => {
+ const { controller, setSelection, setContainerRect } = setup();
+ // A container far shorter than any realistic widget height forces the
+ // vertical clamp to floor the overlay at the top.
+ setContainerRect({ top: 0, left: 0, width: 600, height: 20 });
+ setSelection('hello world', { top: 10, bottom: 15, left: 0, right: 10, width: 10, height: 5 });
+
+ const style = inputDomNode(controller).style;
+ assert.notStrictEqual(style.display, 'none');
+ assert.strictEqual(parseFloat(style.top), 0);
+ });
+
+ test('stays visible with a busy state while the request is pending, then clears once it settles', async () => {
+ let resolveCreate!: (chat: IChat) => void;
+ const pending = new Promise(resolve => { resolveCreate = resolve; });
+ const { controller, setSelection, callOrder, sideChat } = setup({
+ createSideChatInSession: async (_session, _sourceChat, turnId, selection) => {
+ callOrder.push(`create:${turnId}:${selection?.text}`);
+ return pending;
+ },
+ });
+ setSelection('hello world');
+ submitViaClick(controller, 'what does this mean?');
+
+ assert.strictEqual(isInputBusy(controller), true, 'input must report busy while the request is pending');
+ assert.strictEqual(inputTextArea(controller).disabled, true, 'the textarea must be disabled while pending');
+ assert.notStrictEqual(inputDomNode(controller).style.display, 'none', 'the overlay must stay visible while pending, not be dismissed eagerly');
+
+ resolveCreate(sideChat);
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ assert.deepStrictEqual(callOrder, [
+ 'create:turn-1:hello world',
+ `open:${sideChat.resource.toString()}`,
+ `send:${sideChat.resource.toString()}:what does this mean?`,
+ ]);
+ assert.strictEqual(isInputBusy(controller), false, 'busy clears once the orchestration settles');
+ });
+
+ test('prevents duplicate submission (click and Enter) while a request is pending', async () => {
+ let resolveCreate!: (chat: IChat) => void;
+ const pending = new Promise(resolve => { resolveCreate = resolve; });
+ let createCalls = 0;
+ const { controller, setSelection, sideChat } = setup({
+ createSideChatInSession: async () => {
+ createCalls++;
+ return pending;
+ },
+ });
+ setSelection('hello world');
+ submitViaClick(controller, 'what does this mean?');
+ assert.strictEqual(createCalls, 1);
+
+ // A second click and an Enter keypress while the first request is still
+ // in flight must not create a second side chat.
+ inputDomNode(controller).querySelector('.action-label')!.click();
+ dispatchKey(inputTextArea(controller), 'Enter');
+ assert.strictEqual(createCalls, 1, 'only the first submission must create a side chat');
+
+ resolveCreate(sideChat);
+ await new Promise(resolve => setTimeout(resolve, 0));
+ });
+
+ test('ignores Escape and selection-change dismissal while a request is pending', async () => {
+ let resolveCreate!: (chat: IChat) => void;
+ const pending = new Promise(resolve => { resolveCreate = resolve; });
+ const { controller, setSelection, sideChat } = setup({
+ createSideChatInSession: async () => pending,
+ });
+ setSelection('hello world');
+ submitViaClick(controller, 'what does this mean?');
+ assert.notStrictEqual(inputDomNode(controller).style.display, 'none');
+
+ dispatchKey(inputTextArea(controller), 'Escape');
+ assert.notStrictEqual(inputDomNode(controller).style.display, 'none', 'Escape must not dismiss a pending request');
+
+ setSelection('');
+ assert.notStrictEqual(inputDomNode(controller).style.display, 'none', 'an invalidated selection must not dismiss a pending request');
+
+ resolveCreate(sideChat);
+ await new Promise(resolve => setTimeout(resolve, 0));
+ });
+
+ test('restores the entered question and re-enables the input when the side chat fails to create', async () => {
+ const { controller, setSelection, notificationService } = setup({
+ createSideChatInSession: async () => { throw new Error('boom'); },
+ });
+ setSelection('hello world');
+ submitViaClick(controller, 'what does this mean?');
+ assert.strictEqual(isInputBusy(controller), true);
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ assert.strictEqual(isInputBusy(controller), false, 'busy must clear on failure');
+ assert.strictEqual(inputTextArea(controller).disabled, false, 'the textarea must be re-enabled on failure');
+ assert.strictEqual(inputTextArea(controller).value, 'what does this mean?', 'the entered question must be restored on failure');
+ assert.notStrictEqual(inputDomNode(controller).style.display, 'none', 'the overlay must stay visible so the user can retry');
+ assert.strictEqual(notificationService.notifications.length, 1);
+ assert.strictEqual(notificationService.notifications[0].severity, Severity.Error);
+ });
+
+ test('same-chat setChat (e.g. a status/interactivity update) preserves a visible draft', () => {
+ const { controller, setSelection, chat } = setup();
+ setSelection('hello world');
+ const textArea = inputTextArea(controller);
+ textArea.value = 'a draft in progress';
+ textArea.dispatchEvent(new Event('input', { bubbles: true }));
+
+ // A new IChat object for the same resource (e.g. ChatView re-invoking
+ // setChat on a status/interactivity observable change) must not
+ // discard the visible draft.
+ controller.setChat(upcastPartial({ resource: chat.resource }));
+
+ assert.notStrictEqual(inputDomNode(controller).style.display, 'none', 'input must stay visible on a same-resource setChat');
+ assert.strictEqual(textArea.value, 'a draft in progress', 'the typed draft must survive a same-resource setChat');
+ });
+
+ test('same-chat setChat does not clear a pending busy submission', async () => {
+ let resolveCreate!: (chat: IChat) => void;
+ const pending = new Promise(resolve => { resolveCreate = resolve; });
+ const { controller, setSelection, callOrder, chat, sideChat } = setup({
+ createSideChatInSession: async (_session, _sourceChat, turnId, selection) => {
+ callOrder.push(`create:${turnId}:${selection?.text}`);
+ return pending;
+ },
+ });
+ setSelection('hello world');
+ submitViaClick(controller, 'what does this mean?');
+ assert.strictEqual(isInputBusy(controller), true);
+
+ // A same-resource setChat (status/interactivity update) must not
+ // force-dismiss or clear busy while the submission is still pending.
+ controller.setChat(upcastPartial({ resource: chat.resource }));
+ assert.strictEqual(isInputBusy(controller), true, 'busy must survive a same-resource setChat');
+ assert.strictEqual(inputTextArea(controller).disabled, true);
+ assert.notStrictEqual(inputDomNode(controller).style.display, 'none');
+
+ // The still-pending original request must be the one that eventually
+ // resolves the busy state — a same-resource setChat must not have
+ // let a second submission race in.
+ resolveCreate(sideChat);
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ assert.deepStrictEqual(callOrder, [
+ 'create:turn-1:hello world',
+ `open:${sideChat.resource.toString()}`,
+ `send:${sideChat.resource.toString()}:what does this mean?`,
+ ]);
+ assert.strictEqual(isInputBusy(controller), false);
+ });
+
+ test('different-resource setChat force-dismisses even while busy', async () => {
+ let resolveCreate!: (chat: IChat) => void;
+ const pending = new Promise(resolve => { resolveCreate = resolve; });
+ const { controller, setSelection } = setup({
+ createSideChatInSession: async () => pending,
+ });
+ setSelection('hello world');
+ submitViaClick(controller, 'what does this mean?');
+ assert.strictEqual(isInputBusy(controller), true);
+
+ controller.setChat(upcastPartial({ resource: URI.parse('test:///chat/other') }));
+
+ assert.strictEqual(inputDomNode(controller).style.display, 'none', 'a genuine chat change must dismiss even a busy overlay');
+ assert.strictEqual(isInputBusy(controller), false);
+
+ // A real click also clears the browser's text selection; reflect that
+ // here so a `selectionchange` the browser fires asynchronously as
+ // focus leaves the now-hidden input (which the mocked `getSelection`
+ // would otherwise still report as "hello world") can't reopen it.
+ setSelection('');
+
+ // The now-orphaned request settling afterwards must not reopen the overlay.
+ resolveCreate(upcastPartial({ resource: URI.parse('test:///chat/side') }));
+ await new Promise(resolve => setTimeout(resolve, 0));
+ assert.strictEqual(inputDomNode(controller).style.display, 'none');
+ });
+
+ test('a success that settles after a different-resource setChat does not reopen, refocus, or mutate the overlay', async () => {
+ let resolveCreate!: (chat: IChat) => void;
+ const pending = new Promise(resolve => { resolveCreate = resolve; });
+ const { controller, setSelection, focusResponseItemCalls } = setup({
+ createSideChatInSession: async () => pending,
+ });
+ setSelection('hello world');
+ submitViaClick(controller, 'what does this mean?');
+
+ controller.setChat(upcastPartial({ resource: URI.parse('test:///chat/other') }));
+ setSelection('');
+ const focusCallsAtDismiss = focusResponseItemCalls.length;
+
+ resolveCreate(upcastPartial({ resource: URI.parse('test:///chat/side') }));
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ assert.strictEqual(inputDomNode(controller).style.display, 'none', 'a stale success must not reopen the overlay');
+ assert.strictEqual(inputTextArea(controller).value, '', 'a stale success must not mutate the (already cleared) input value');
+ assert.deepStrictEqual(focusResponseItemCalls.length, focusCallsAtDismiss, 'a stale success must not refocus the transcript');
+ });
+
+ test('a failure that settles after a different-resource setChat does not reopen, refocus, mutate, or notify', async () => {
+ let rejectCreate!: (err: Error) => void;
+ const pending = new Promise((_resolve, reject) => { rejectCreate = reject; });
+ const { controller, setSelection, notificationService, focusResponseItemCalls } = setup({
+ createSideChatInSession: async () => pending,
+ });
+ setSelection('hello world');
+ submitViaClick(controller, 'what does this mean?');
+
+ controller.setChat(upcastPartial({ resource: URI.parse('test:///chat/other') }));
+ setSelection('');
+ const focusCallsAtDismiss = focusResponseItemCalls.length;
+
+ rejectCreate(new Error('boom'));
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ assert.strictEqual(inputDomNode(controller).style.display, 'none', 'a stale failure must not reopen the overlay');
+ assert.strictEqual(inputTextArea(controller).value, '', 'a stale failure must not restore the failed question into the (already cleared) input');
+ assert.deepStrictEqual(focusResponseItemCalls.length, focusCallsAtDismiss, 'a stale failure must not refocus the input');
+ assert.strictEqual(notificationService.notifications.length, 0, 'a stale failure must not surface a retry notification for an abandoned overlay');
+ });
+
+ test('plain Enter submits and prevents the default newline', () => {
+ const { controller, setSelection, callOrder } = setup();
+ setSelection('hello world');
+ const textArea = inputTextArea(controller);
+ textArea.value = 'what does this mean?';
+ textArea.dispatchEvent(new Event('input', { bubbles: true }));
+
+ const event = dispatchKey(textArea, 'Enter');
+
+ assert.strictEqual(event.defaultPrevented, true, 'plain Enter must prevent the default newline');
+ assert.ok(callOrder[0]?.startsWith('create:turn-1:hello world'), 'plain Enter must submit');
+ });
+
+ test('Shift+Enter inserts a newline instead of submitting', () => {
+ const { controller, setSelection, callOrder } = setup();
+ setSelection('hello world');
+ const textArea = inputTextArea(controller);
+ textArea.value = 'what does this mean?';
+ textArea.dispatchEvent(new Event('input', { bubbles: true }));
+
+ const event = dispatchKey(textArea, 'Enter', { shiftKey: true });
+
+ assert.strictEqual(event.defaultPrevented, false, 'Shift+Enter must let the textarea insert a newline');
+ assert.deepStrictEqual(callOrder, [], 'Shift+Enter must not submit');
+ assert.notStrictEqual(inputDomNode(controller).style.display, 'none', 'Shift+Enter must not dismiss the overlay');
+ });
+
+ test('Enter during IME composition does not submit', () => {
+ const { controller, setSelection, callOrder } = setup();
+ setSelection('hello world');
+ const textArea = inputTextArea(controller);
+ textArea.value = 'what does this mean?';
+ textArea.dispatchEvent(new Event('input', { bubbles: true }));
+
+ const event = dispatchKey(textArea, 'Enter', { isComposing: true });
+
+ assert.strictEqual(event.defaultPrevented, false, 'Enter during IME composition must not be prevented');
+ assert.deepStrictEqual(callOrder, [], 'Enter during IME composition must not submit');
+ assert.notStrictEqual(inputDomNode(controller).style.display, 'none', 'Enter during IME composition must not dismiss the overlay');
+ });
+});
diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts
index 2ad61a716da037..366dbbb92a48ef 100644
--- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts
+++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts
@@ -11,6 +11,7 @@ import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType
import { IAccessibleViewImplementation } from '../../../../../platform/accessibility/browser/accessibleViewRegistry.js';
import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';
import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js';
+import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js';
import { AccessibilityVerbositySettingId } from '../../../accessibility/browser/accessibilityConfiguration.js';
import { INLINE_CHAT_ID } from '../../../inlineChat/common/inlineChat.js';
import { TerminalContribCommandId } from '../../../terminal/terminalContribExports.js';
@@ -60,7 +61,7 @@ export class AgentChatAccessibilityHelp implements IAccessibleViewImplementation
}
}
-export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView', keybindingService: IKeybindingService, supportsFileReferences: boolean): string {
+export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView', keybindingService: IKeybindingService, supportsFileReferences: boolean, isSessionsWindow: boolean = false): string {
const content = [];
if (type === 'panelChat' || type === 'quickChat' || type === 'editsView' || type === 'agentView') {
content.push(localize('chat.fileChangesDisclosure', 'File change summaries show the total files, additions, and deletions. Focus the disclosure and press Enter or Space to show or hide the individual files.'));
@@ -108,6 +109,9 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui
content.push(localize('chat.previousQuestionCarouselQuestion', 'When a chat question is focused, move to the previous question{0}.', ''));
content.push(localize('chat.nextQuestionCarouselQuestion', 'When a chat question is focused, move to the next question{0}.', ''));
content.push(localize('chat.focusTip', 'When a tip appears, toggle focus between the tip and the chat input{0}.', ''));
+ if (isSessionsWindow) {
+ content.push(localize('sessions.selectionSideChat', 'When you select text within an assistant response, an Ask Question input appears near the selection. Type a question and press Enter to start a new side chat scoped to that selection.'));
+ }
}
if (type === 'editsView' || type === 'agentView') {
if (type === 'agentView') {
@@ -159,6 +163,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui
export function getChatAccessibilityHelpProvider(accessor: ServicesAccessor, editor: ICodeEditor | undefined, type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView'): AccessibleContentProvider | undefined {
const widgetService = accessor.get(IChatWidgetService);
const keybindingService = accessor.get(IKeybindingService);
+ const environmentService = accessor.get(IWorkbenchEnvironmentService);
const widget = widgetService.lastFocusedWidget;
if (!widget) {
@@ -172,7 +177,7 @@ export function getChatAccessibilityHelpProvider(accessor: ServicesAccessor, edi
const cachedPosition = inputEditor.getPosition();
inputEditor.getSupportedActions();
- const helpText = getAccessibilityHelpText(type, keybindingService, widget.supportsFileReferences);
+ const helpText = getAccessibilityHelpText(type, keybindingService, widget.supportsFileReferences, environmentService.isSessionsWindow);
return new AccessibleContentProvider(
type === 'panelChat' ? AccessibleViewProviderId.PanelChat : type === 'inlineChat' ? AccessibleViewProviderId.InlineChat : type === 'agentView' ? AccessibleViewProviderId.AgentChat : AccessibleViewProviderId.QuickChat,
{ type: AccessibleViewType.Help },
diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts
index 6221b6a44dc87d..239f43057446b2 100644
--- a/src/vs/workbench/contrib/chat/browser/chat.ts
+++ b/src/vs/workbench/contrib/chat/browser/chat.ts
@@ -461,6 +461,10 @@ export interface IChatWidget {
getCodeBlockInfosForResponse(response: IChatResponseViewModel): IChatCodeBlockInfo[];
getFileTreeInfosForResponse(response: IChatResponseViewModel): IChatFileTreeInfo[];
getLastFocusedFileTreeForResponse(response: IChatResponseViewModel): IChatFileTreeInfo | undefined;
+ /**
+ * Returns the currently rendered chat item containing the node, if any.
+ */
+ getElementFromNode(node: HTMLElement): ChatTreeItem | undefined;
clear(targetSessionType?: string): Promise;
getViewState(): IChatModelInputState | undefined;
lockToCodingAgent(name: string, displayName: string, agentId?: string, agentHostProviderId?: string): void;
diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts
index c94a95963f37fd..e14c252e618b91 100644
--- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts
+++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts
@@ -1013,6 +1013,13 @@ export class ChatListWidget extends Disposable {
return this._renderer.getTemplateDataForRequestId(requestId);
}
+ /**
+ * Returns the currently rendered chat item containing the node.
+ */
+ getElementFromNode(node: HTMLElement): ChatTreeItem | undefined {
+ return this._renderer.getElementFromNode(node);
+ }
+
/**
* Update renderer options.
*/
diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts
index 6472e448939e61..df4676685db5a7 100644
--- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts
+++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts
@@ -3139,6 +3139,10 @@ export class ChatWidget extends Disposable implements IChatWidget {
return this.listWidget.getLastFocusedFileTreeForResponse(response);
}
+ getElementFromNode(node: HTMLElement): ChatTreeItem | undefined {
+ return this.listWidget.getElementFromNode(node);
+ }
+
focusResponseItem(lastFocused?: boolean): void {
this.listWidget.focusLastItem(lastFocused);
}
diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts
index 1ab6da84e2d970..2ec396fb03835a 100644
--- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts
+++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts
@@ -24,4 +24,18 @@ suite('Chat Accessibility Help', () => {
unsupported: false,
});
});
+
+ test('only describes the selection side chat affordance in the sessions window', () => {
+ const keybindingService = {
+ lookupKeybindings: () => [],
+ } as unknown as IKeybindingService;
+
+ assert.deepStrictEqual({
+ sessionsWindow: getAccessibilityHelpText('agentView', keybindingService, true, true).includes('Ask Question'),
+ regularWindow: getAccessibilityHelpText('agentView', keybindingService, true, false).includes('Ask Question'),
+ }, {
+ sessionsWindow: true,
+ regularWindow: false,
+ });
+ });
});