Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/skills/sessions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,34 @@ 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}
messages:
- 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: |-
<side-chat-context>
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
</side-chat-context>

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
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,34 @@ version: 1
dialect: anthropic
exchanges:
- request:
model: claude-haiku-4.5
model: claude-sonnet-5
system: ${system}
messages:
- role: user
content: Remember the exact token SIDECHAT42 for a later question. Reply with exactly "ready".
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
content: Remember the exact token SIDECHAT42 for a later question. Reply with exactly "ready".
- role: assistant
content: ready
- role: user
content: What exact token did I ask you to remember? Reply with only the token.
content: |-
<side-chat-context>
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
</side-chat-context>

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
17 changes: 11 additions & 6 deletions src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
async function createPeer(sessionUri: string, id: string, source?: { chat: string; turnId: string; kind: ChatSourceKind; selection?: { text: string; responsePartId?: string } }): Promise<string> {
const chat = buildChatUri(sessionUri, id);
await context.client.call('createChat', {
channel: sessionUri,
Expand Down Expand Up @@ -630,33 +630,38 @@ 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<SubscribeResult>('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),
sessionState(sessionUri),
]);

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);
Expand Down
86 changes: 86 additions & 0 deletions src/vs/sessions/SESSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading