From 7a9cd87b8be872e8bc5f2bc9fc5ddfbd4ebed7df Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 22 Jun 2026 14:12:48 -0400 Subject: [PATCH] voice mode: enable transcript scrolling in chat input and aux window (#322390) --- .github/skills/sessions/SKILL.md | 1 + .../lib/stylelint/vscode-known-variables.json | 1 + .../browser/agentsVoice.contribution.ts | 8 +-- .../browser/agentsVoiceSessionsPicker.ts | 2 +- .../agentsVoice/browser/agentsVoiceWidget.ts | 57 ++++++++++++++----- .../browser/components/headerComponent.ts | 20 ++++++- .../browser/components/transcriptComponent.ts | 54 ++++++++---------- .../chat/browser/widget/media/chat.css | 33 +++++++++-- .../widgetHosts/viewPane/chatViewPane.ts | 54 +++++++++--------- .../viewPane/media/chatViewPane.css | 57 +++++++++++++++++++ 10 files changed, 200 insertions(+), 87 deletions(-) diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index c79c9c1f385..e02331b1305 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -43,6 +43,7 @@ Then read the relevant spec for the area you are changing (see table below). If - **Selected custom agent must be in the SDK's `customAgents`, not just `pluginDirectories`**: The Copilot SDK validates the session-start `agent:` option (passed to `createSession`/`resumeSession`) against the `customAgents` list **by name only** — it does NOT consult `pluginDirectories`. `copilotSessionLauncher._buildSessionConfig` deliberately omits agents from file-dir plugins from `customAgents` (relying on the SDK's `pluginDirectories` discovery to avoid duplicates), so selecting a plugin/extension-contributed agent (e.g. "Inbox") otherwise fails with `Custom agent '' not found`. The fix (`toSdkSessionCustomAgents`) force-adds the resolved selected agent into `customAgents` while every other file-dir agent still loads via `pluginDirectories`. Note the agent picker offers VS Code chat modes from `IChatModeService`, but only `plugin`/`extension` storage agents are synced to the host (`SYNCABLE_STORAGE_SOURCES`); `user`/`local` agents are never synced, so `_resolveAgentName` returns `undefined` for them and no `agent:` is sent. - **Derive SDK custom-agent names exactly like `parseAgentFile`**: `_resolveAgentName` resolves the selected agent through the plugin parser, which trims the frontmatter `name` (`getStringValue('name')?.trim() || nameFromFile`). When building the SDK `customAgents` list (`toSdkCustomAgents`), derive the name the same way (`?.trim() || agent.name`); reading the raw frontmatter `name` without trimming yields a config name that won't match the trimmed `resolvedAgentName`, so the SDK still rejects the session with `Custom agent '' not found`. - **Peer chats have no server `summary`, so dedup side-channel dispatch against the last value sent for that chat**: equality guards before dispatching `SessionModelChanged`/`SessionAgentChanged` compare against `summary.model`/`summary.agent`, which only exist for the session's default chat. For peer chats, track the last-dispatched model/agent on the `AgentHostChatSession` instance (auto-cleaned on dispose) and diff against that — otherwise every peer-chat turn redundantly re-dispatches (and re-resolves the agent), and an intentional "clear selection" (`undefined`) can't be detected. +- **Scrollable transcript surfaces must use workbench scrollbars**: Don't make Agents/voice transcript regions scrollable with native `overflow-y: auto` on the content node. Wrap transcript content in `DomScrollableElement`/list widgets so scrollbars match VS Code theming and remain usable in narrow auxiliary-window layouts. ## Capturing Feedback (meta-rule) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index bfd8a7f005d..0bba2485ea6 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -954,6 +954,7 @@ "--session-view-foreground", "--session-view-centered-content-max-width", "--chat-editing-last-edit-shift", + "--chat-voice-icon-glow-color", "--chat-current-response-min-height", "--chat-smooth-delay", "--chat-smooth-duration", diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts index d3283240a1c..4df2651e57a 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts @@ -180,8 +180,8 @@ CommandsRegistry.registerCommand('_agentsVoice.openWindow', async (accessor) => } }); -// --- Mic button in Chat toolbar --- -// Shows mic (unfilled) normally, mic-filled when actively listening. +// --- Voice mode button in Chat toolbar --- +// Shows the voice mode icon in both idle and active states. // Click to connect if disconnected, or toggle PTT if connected. // The disconnect button (shown when connected) indicates active voice mode. @@ -217,7 +217,7 @@ registerAction2(class extends Action2 { super({ id: 'agentsVoice.startVoiceInChat', title: nls.localize2('agentsVoice.startVoiceInChat', "Voice Mode"), - icon: Codicon.mic, + icon: Codicon.voiceMode, precondition: ContextKeyExpr.equals('config.agents.voice.enabled', true), menu: { id: MenuId.ChatExecute, @@ -256,7 +256,7 @@ registerAction2(class extends Action2 { super({ id: 'agentsVoice.pttStopInChat', title: nls.localize2('agentsVoice.pttStopInChat', "Voice Mode: Stop Recording"), - icon: Codicon.micFilled, + icon: Codicon.voiceMode, precondition: ContextKeyExpr.and( ContextKeyExpr.equals('config.agents.voice.enabled', true), AGENTS_VOICE_ACTIVE.isEqualTo(true), diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceSessionsPicker.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceSessionsPicker.ts index 4415a525765..7fb82547612 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceSessionsPicker.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceSessionsPicker.ts @@ -21,7 +21,7 @@ interface IVoiceSessionPickItem extends IQuickPickItem { } const setTargetButton: IQuickInputButton = { - iconClass: ThemeIcon.asClassName(Codicon.mic), + iconClass: ThemeIcon.asClassName(Codicon.voiceMode), tooltip: localize('voiceSessions.setTarget', "Set as voice target") }; diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts index 6bb8ca63c1d..6b0d8a38756 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts @@ -12,7 +12,7 @@ import { getWindow } from '../../../../base/browser/dom.js'; import { AGENTS_VOICE_WINDOW_DEFAULT_WIDTH, AGENTS_VOICE_WINDOW_DEFAULT_HEIGHT } from '../common/agentsVoice.js'; import { createHeader } from './components/headerComponent.js'; import { createStatusRows } from './components/statusRowsComponent.js'; -import { createTranscript, updateTranscriptOverflowState } from './components/transcriptComponent.js'; +import { createTranscript } from './components/transcriptComponent.js'; import { createSessionList, type SessionRowData, type SessionGroupData } from './components/sessionListComponent.js'; import { createFeedbackDialog, type FeedbackDialogState } from './components/feedbackDialog.js'; import { createOnboarding } from './components/onboardingComponent.js'; @@ -158,7 +158,8 @@ export class AgentsVoiceWidget extends Disposable { private readonly _onboardingComponent = createOnboarding(); private readonly _feedbackDialogComponent = createFeedbackDialog(); private readonly _voiceBarComponent = createVoiceBar(); - private readonly _transcriptComponent = createTranscript(); + private readonly _transcriptComponent = this._register(createTranscript()); + private readonly _inputBoxTranscriptComponent = this._register(createTranscript()); private readonly _statusRowsComponent = createStatusRows(); private readonly _sessionListComponent = createSessionList(); @@ -266,6 +267,13 @@ export class AgentsVoiceWidget extends Disposable { styleEl.textContent = ` @property --voice-processing-angle { syntax: ''; inherits: false; initial-value: 135deg; } @keyframes voice-processing-spin { from { --voice-processing-angle: 135deg; } to { --voice-processing-angle: 495deg; } } + @keyframes agents-voice-input-icon-pulse { + 0%, 100% { box-shadow: 0 0 4px rgba(var(--agents-voice-input-icon-rgb, 88,166,255), 0.45); } + 50% { box-shadow: 0 0 10px rgba(var(--agents-voice-input-icon-rgb, 88,166,255), 0.75); } + } + .monaco-workbench.monaco-enable-motion .agents-voice-mode-button.agents-voice-mode-active { + animation: agents-voice-input-icon-pulse 1.4s ease-in-out infinite; + } .processing { overflow: visible !important; } .processing::before { content: ''; position: absolute; inset: -1px; border-radius: inherit; padding: 1px; @@ -297,7 +305,9 @@ export class AgentsVoiceWidget extends Disposable { this._inputBoxPlaceholder = dom.$('span'); this._inputBoxPlaceholder.style.cssText = `font-size:${FONT_SIZE.body};color:var(--vscode-input-placeholderForeground, var(--vscode-descriptionForeground));user-select:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;`; - this._inputBoxContainer.append(this._inputBoxPlaceholder); + this._inputBoxTranscriptComponent.element.style.width = '100%'; + this._inputBoxTranscriptComponent.element.style.display = 'none'; + this._inputBoxContainer.append(this._inputBoxPlaceholder, this._inputBoxTranscriptComponent.element); // Toolbar row below the input box this._inputBoxToolbar = dom.$('div'); @@ -317,7 +327,7 @@ export class AgentsVoiceWidget extends Disposable { }; // Mic button - this._inputBoxMicBtn = dom.$('span.codicon.codicon-mic'); + this._inputBoxMicBtn = dom.$('span.codicon.codicon-voice-mode.agents-voice-mode-button'); this._inputBoxMicBtn.role = 'button'; this._inputBoxMicBtn.tabIndex = 0; this._inputBoxMicBtn.ariaLabel = localize('agentsVoice.pushToTalkSpace', "Push to talk (Space)"); @@ -448,7 +458,6 @@ export class AgentsVoiceWidget extends Disposable { const renderDisposable = autorun(reader => { this._updateDOM(reader); getWindow(this.container).requestAnimationFrame(() => { - updateTranscriptOverflowState(this.container); this.callbacks.onResize(); }); }); @@ -605,20 +614,20 @@ export class AgentsVoiceWidget extends Disposable { this._transcriptComponent.element.style.padding = '8px 12px'; this._transcriptComponent.element.style.borderBottom = '1px solid var(--vscode-widget-border, var(--vscode-input-border, transparent))'; this._transcriptComponent.update({ turns: transcriptTurns, chatStyle: true }); - // Hide the input box placeholder since transcript is shown above this._inputBoxPlaceholder!.style.display = 'none'; + this._inputBoxTranscriptComponent.element.style.display = 'none'; } else { - // Show transcript text inside the placeholder (no purple coloring) - this._inputBoxPlaceholder!.style.display = ''; + this._inputBoxPlaceholder!.style.display = 'none'; this._transcriptComponent.element.style.display = 'none'; this._transcriptComponent.element.style.padding = ''; this._transcriptComponent.element.style.borderBottom = ''; - const lastTurn = transcriptTurns[transcriptTurns.length - 1]; - this._inputBoxPlaceholder!.textContent = lastTurn?.text ?? ''; + this._inputBoxTranscriptComponent.element.style.display = ''; + this._inputBoxTranscriptComponent.update({ turns: transcriptTurns, chatStyle: true, scrollToTop: true }); } } else { // Show placeholder this._inputBoxPlaceholder!.style.display = ''; + this._inputBoxTranscriptComponent.element.style.display = 'none'; this._transcriptComponent.element.style.display = 'none'; const keyLabel = this._pttKeyLabel.read(reader); if (showConnected) { @@ -626,7 +635,7 @@ export class AgentsVoiceWidget extends Disposable { } else if (keyLabel) { this._inputBoxPlaceholder!.textContent = localize('agentsVoice.holdToTalk', "Hold {0} to talk", keyLabel); } else { - this._inputBoxPlaceholder!.textContent = localize('agentsVoice.clickMicToTalk', "Click mic to talk"); + this._inputBoxPlaceholder!.textContent = localize('agentsVoice.clickMicToTalk', "Click voice mode to talk"); } } @@ -669,10 +678,13 @@ export class AgentsVoiceWidget extends Disposable { : voiceState === 'speaking' ? 'var(--vscode-agentsVoice-speakingForeground)' : 'var(--vscode-descriptionForeground)'; this._inputBoxMicBtn!.style.color = micColor; - // Toggle filled state when actively listening or speaking - const micFilled = voiceState === 'listening' || voiceState === 'speaking'; - this._inputBoxMicBtn!.classList.toggle('codicon-mic', !micFilled); - this._inputBoxMicBtn!.classList.toggle('codicon-mic-filled', micFilled); + const micIsActive = voiceState === 'listening' || voiceState === 'speaking'; + this._inputBoxMicBtn!.classList.toggle('agents-voice-mode-active', micIsActive); + this._inputBoxMicBtn!.style.setProperty('--agents-voice-input-icon-rgb', voiceState === 'speaking' ? '163,113,247' : '88,166,255'); + this._inputBoxMicBtn!.style.borderRadius = '50%'; + if (!micIsActive) { + this._inputBoxMicBtn!.style.boxShadow = 'none'; + } this._inputBoxMicBtn!.onmousedown = (e: MouseEvent) => { e.preventDefault(); this.callbacks.pttDown(); }; this._inputBoxMicBtn!.onmouseup = () => { this.callbacks.pttUp(); }; @@ -980,6 +992,9 @@ export class AgentsVoiceWidget extends Disposable { this._inputBoxContainer.style.borderColor = 'var(--vscode-input-border, transparent)'; this._inputBoxContainer.style.boxShadow = 'none'; } + if (this._inputBoxMicBtn) { + this._inputBoxMicBtn.style.boxShadow = 'none'; + } return; } @@ -1009,6 +1024,18 @@ export class AgentsVoiceWidget extends Disposable { this._inputBoxContainer.style.boxShadow = `0 0 ${shadowSpread}px rgba(${r},${shadowAlpha}), inset 0 0 ${shadowSpread * 0.4}px rgba(${r},${shadowAlpha * 0.3})`; } + if (this._inputBoxMicBtn) { + const iconGlowActive = voiceState === 'listening' || voiceState === 'speaking'; + if (iconGlowActive) { + const r = voiceState === 'speaking' ? '163,113,247' : '88,166,255'; + const shadowSpread = 3 + intensity * 8; + const shadowAlpha = 0.2 + intensity * 0.45; + this._inputBoxMicBtn.style.boxShadow = `0 0 ${shadowSpread}px rgba(${r},${shadowAlpha})`; + } else { + this._inputBoxMicBtn.style.boxShadow = 'none'; + } + } + // Classic layout glow div this._glowDiv.style.display = ''; const baseOpacity = 0.15 + intensity * 0.4; diff --git a/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts b/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts index 4ab2aee2f2d..2d795e56fb8 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts @@ -58,8 +58,8 @@ export function createHeader(): HeaderComponent { const copilotIcon = document.createElement('img'); copilotIcon.style.cssText = 'width:18px;height:18px;margin-right:4px;'; - // Mic button - const micBtn = dom.$('span.codicon.codicon-mic'); + // Voice mode button + const micBtn = dom.$('span.codicon.codicon-voice-mode'); micBtn.role = 'button'; micBtn.tabIndex = 0; micBtn.ariaLabel = localize('agentsVoice.pushToTalkSpace', "Push to talk (Space)"); @@ -123,6 +123,13 @@ export function createHeader(): HeaderComponent { connStyle.textContent = ` .voice-conn-indicator:hover .voice-conn-dot { display: none !important; } .voice-conn-indicator:hover .voice-conn-disconnect { display: inline-block !important; color: var(--vscode-errorForeground, #f44) !important; } + @keyframes agents-voice-icon-pulse { + 0%, 100% { box-shadow: 0 0 4px rgba(var(--agents-voice-icon-rgb, 88,166,255), 0.45); } + 50% { box-shadow: 0 0 10px rgba(var(--agents-voice-icon-rgb, 88,166,255), 0.7); } + } + .monaco-workbench.monaco-enable-motion .agents-voice-mode-active { + animation: agents-voice-icon-pulse 1.4s ease-in-out infinite; + } `; container.append(copilotIcon, micBtn, placeholderText, gearBtn, connIndicator, spacer, popoutBtn, closeBtn, connStyle); @@ -145,6 +152,13 @@ export function createHeader(): HeaderComponent { : props.voiceState === 'speaking' ? 'var(--vscode-agentsVoice-speakingForeground)' : 'var(--vscode-descriptionForeground)'; micBtn.style.color = micColor; + const micIsActive = props.voiceState === 'listening' || props.voiceState === 'speaking'; + micBtn.classList.toggle('agents-voice-mode-active', micIsActive); + micBtn.style.setProperty('--agents-voice-icon-rgb', props.voiceState === 'speaking' ? '163,113,247' : '88,166,255'); + micBtn.style.borderRadius = '50%'; + if (!micIsActive) { + micBtn.style.boxShadow = 'none'; + } micBtn.onmouseenter = () => { micBtn.style.color = 'var(--vscode-foreground)'; }; micBtn.onmouseleave = () => { micBtn.style.color = micColor; }; micBtn.onmousedown = props.onMicDown; @@ -155,7 +169,7 @@ export function createHeader(): HeaderComponent { const keyLabel = props.pttKeyLabel; const holdText = keyLabel ? localize('agentsVoice.holdToTalk', "Hold {0} to talk", keyLabel) - : localize('agentsVoice.clickMicToTalk', "Click mic to talk"); + : localize('agentsVoice.clickMicToTalk', "Click voice mode to talk"); placeholderText.textContent = holdText; placeholderText.ariaLabel = holdText; placeholderText.onclick = props.onConnectClick; diff --git a/src/vs/workbench/contrib/agentsVoice/browser/components/transcriptComponent.ts b/src/vs/workbench/contrib/agentsVoice/browser/components/transcriptComponent.ts index 7e9ba6b89e1..10c29112bd1 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/components/transcriptComponent.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/components/transcriptComponent.ts @@ -4,40 +4,35 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; +import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; +import { DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; import type { ITranscriptTurn } from '../../../chat/browser/voiceClient/voiceSessionController.js'; import { COLOR, FONT_SIZE } from './tokens.js'; const MAX_LINES = 10; const LINE_HEIGHT = 1.4; const MAX_HEIGHT = `${MAX_LINES * LINE_HEIGHT}em`; -const FADE_PX = 20; const USER_CONTAINER_STYLE = [ - `max-height:${MAX_HEIGHT}`, - 'overflow:hidden', - 'display:flex', - 'flex-direction:column-reverse', + 'overflow-x:hidden', ].join(';'); const ASSISTANT_STYLE = [ - 'display:-webkit-box', - `-webkit-line-clamp:${MAX_LINES}`, - '-webkit-box-orient:vertical', - 'overflow:hidden', + 'overflow-x:hidden', + 'white-space:pre-wrap', `color:${COLOR.assistantTranscript}`, ].join(';'); const TRANSCRIPT_CSS = ` @keyframes textPulse { 0%,100%{opacity:0.9} 50%{opacity:0.4} } - .voice-user-transcript.overflowing { - mask-image: linear-gradient(to bottom, transparent, black ${FADE_PX}px); - -webkit-mask-image: linear-gradient(to bottom, transparent, black ${FADE_PX}px); - } `; export interface TranscriptProps { readonly turns: readonly ITranscriptTurn[]; readonly chatStyle?: boolean; + /** When true, keep the scroll anchored to the top instead of the bottom. */ + readonly scrollToTop?: boolean; } function createUserTurn(turn: ITranscriptTurn, chatStyle?: boolean): HTMLElement { @@ -89,14 +84,20 @@ export interface TranscriptComponent { update(props: TranscriptProps): void; } -export function createTranscript(): TranscriptComponent { - const wrapper = dom.$('div'); +export function createTranscript(): TranscriptComponent & IDisposable { + const store = new DisposableStore(); const container = dom.$('div'); - container.style.cssText = `display:flex;flex-direction:column;gap:2px;padding:2px 2px 4px;font-size:${FONT_SIZE.body};line-height:${LINE_HEIGHT};word-break:break-word;`; + container.style.cssText = `display:flex;flex-direction:column;gap:2px;padding:2px 2px 4px;font-size:${FONT_SIZE.body};line-height:${LINE_HEIGHT};word-break:break-word;max-height:${MAX_HEIGHT};overflow:hidden;box-sizing:border-box;`; + const scrollable = store.add(new DomScrollableElement(container, { + horizontal: ScrollbarVisibility.Hidden, + vertical: ScrollbarVisibility.Auto, + })); + const wrapper = scrollable.getDomNode(); + wrapper.style.maxHeight = MAX_HEIGHT; const style = dom.$('style'); style.textContent = TRANSCRIPT_CSS; - wrapper.append(style, container); + wrapper.prepend(style); return { element: wrapper, @@ -120,20 +121,11 @@ export function createTranscript(): TranscriptComponent { for (const turn of visible) { container.append(turn.speaker === 'user' ? createUserTurn(turn, props.chatStyle) : createAssistantTurn(turn, props.chatStyle)); } + scrollable.scanDomNode(); + scrollable.setScrollPosition({ scrollTop: props.scrollToTop ? 0 : container.scrollHeight }); + }, + dispose() { + store.dispose(); } }; } - -/** - * Toggle the `overflowing` class on each user-transcript container based on - * whether its inner child exceeds its clipped height. Call after each render. - */ -export function updateTranscriptOverflowState(container: HTMLElement): void { - // eslint-disable-next-line no-restricted-syntax - const containers = container.querySelectorAll('.voice-user-transcript'); - containers.forEach(el => { - const inner = el.firstElementChild as HTMLElement | null; - if (!inner) { return; } - el.classList.toggle('overflowing', inner.scrollHeight > (el as HTMLElement).clientHeight); - }); -} diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 19a2b15da6f..d8071ee0e59 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -4354,14 +4354,37 @@ have to be updated for changes to the rules above, or to support more deeply nes font-size: var(--vscode-codiconFontSize-compact); } -/* Voice mode: color the mic icon blue when listening, purple when speaking */ -/* Voice mode: color the mic icon when listening/speaking */ -.chat-input-container.voice-active.voice-listening .chat-input-toolbars .action-label.codicon-mic-filled { - color: var(--vscode-charts-blue, #58a6ff) !important; +@keyframes chat-voice-icon-glow { + 0%, + 100% { + box-shadow: 0 0 4px var(--chat-voice-icon-glow-color); + } + + 50% { + box-shadow: 0 0 9px var(--chat-voice-icon-glow-color); + } } -.chat-input-container.voice-active:not(.voice-listening) .chat-input-toolbars .action-label.codicon-mic-filled { +/* Voice mode: blue when listening */ +.chat-input-container.voice-active.voice-listening .chat-input-toolbars .action-label.codicon-voice-mode { + --chat-voice-icon-glow-color: rgba(88, 166, 255, 0.65); + color: var(--vscode-charts-blue, #58a6ff) !important; + border-radius: 50%; +} + +.monaco-workbench.monaco-enable-motion .chat-input-container.voice-active.voice-listening .chat-input-toolbars .action-label.codicon-voice-mode { + animation: chat-voice-icon-glow 1.4s ease-in-out infinite; +} + +/* Voice mode: purple when speaking */ +.chat-input-container.voice-active:not(.voice-listening) .chat-input-toolbars .action-label.codicon-voice-mode { + --chat-voice-icon-glow-color: rgba(163, 113, 247, 0.65); color: var(--vscode-charts-purple, #a371f7) !important; + border-radius: 50%; +} + +.monaco-workbench.monaco-enable-motion .chat-input-container.voice-active:not(.voice-listening) .chat-input-toolbars .action-label.codicon-voice-mode { + animation: chat-voice-icon-glow 1.4s ease-in-out infinite; } /* Voice mode: green disconnect button */ diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 6b4b5cb8b4e..df51b08cba4 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -8,12 +8,14 @@ import { $, addDisposableListener, append, EventHelper, EventType, getWindow, se import { StandardMouseEvent } from '../../../../../../base/browser/mouseEvent.js'; import { Button } from '../../../../../../base/browser/ui/button/button.js'; import { Orientation, Sash } from '../../../../../../base/browser/ui/sash/sash.js'; +import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Event } from '../../../../../../base/common/event.js'; import { MutableDisposable, toDisposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; import { autorun, IReader } from '../../../../../../base/common/observable.js'; import { isEqual } from '../../../../../../base/common/resources.js'; +import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; @@ -410,18 +412,14 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private _setupVoiceTranscriptOverlay(inputContainerEl: HTMLElement): void { inputContainerEl.style.position = 'relative'; const transcriptOverlay = $('.voice-transcript-overlay'); - // Leave bottom 36px for the toolbar (Agent, model picker, mic, send) - transcriptOverlay.style.cssText = 'display:none;position:absolute;top:0;left:0;right:0;bottom:36px;z-index:10;padding:8px 12px;font-size:13px;line-height:1.4;word-break:break-word;overflow:hidden;pointer-events:none;background:var(--vscode-input-background, transparent);border-radius:inherit;border-bottom-left-radius:0;border-bottom-right-radius:0;'; - inputContainerEl.append(transcriptOverlay); - - const style = document.createElement('style'); - style.textContent = ` - @keyframes voiceTextPulse { 0%,100%{opacity:0.9} 50%{opacity:0.4} } - .voice-transcript-overlay .committed { color: var(--vscode-foreground); } - .voice-transcript-overlay .partial { color: var(--vscode-foreground); opacity:0.6; font-style:italic; animation: voiceTextPulse 1.5s ease-in-out infinite; } - .voice-transcript-overlay .assistant-text { color: var(--vscode-descriptionForeground); display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; } - `; - transcriptOverlay.append(style); + const transcriptScrollable = this._register(new DomScrollableElement(transcriptOverlay, { + horizontal: ScrollbarVisibility.Hidden, + vertical: ScrollbarVisibility.Auto, + })); + const transcriptOverlayNode = transcriptScrollable.getDomNode(); + transcriptOverlayNode.classList.add('voice-transcript-overlay-scrollable'); + transcriptOverlayNode.style.display = 'none'; + inputContainerEl.append(transcriptOverlayNode); // Dynamic audio-reactive glow animation (matches aux window behavior) let animFrameId: number | undefined; @@ -502,7 +500,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const visible = turns.filter(t => t.text.length > 0 || (t.speaker === 'user' && t.isPartial)); if (!connected) { - transcriptOverlay.style.display = 'none'; + transcriptOverlayNode.style.display = 'none'; + transcriptOverlayNode.classList.remove('has-transcript'); return; } @@ -511,31 +510,34 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const targetSession = this.voiceSessionController.targetSession.read(reader); const currentSession = this._widget?.viewModel?.sessionResource; if (this.agentsVoiceWindowService.isOpen && targetSession && currentSession && targetSession.toString() !== currentSession.toString()) { - transcriptOverlay.style.display = 'none'; + transcriptOverlayNode.style.display = 'none'; + transcriptOverlayNode.classList.remove('has-transcript'); return; } // Show hint when connected but no transcript yet if (visible.length === 0 || !showTranscript) { if (voiceState === 'idle' && visible.length === 0) { - transcriptOverlay.style.display = ''; - while (transcriptOverlay.childNodes.length > 1) { - transcriptOverlay.removeChild(transcriptOverlay.lastChild!); - } + transcriptOverlayNode.style.display = ''; + transcriptOverlayNode.classList.remove('has-transcript'); + transcriptOverlay.replaceChildren(); const hint = $('span.partial'); const kb = this.keybindingService.lookupKeybinding('agentsVoice.pushToTalk'); const kbLabel = kb?.getLabel(); hint.textContent = kbLabel ? localize('voiceMode.pttHint', "Press {0} to talk", kbLabel) - : localize('voiceMode.clickMicHint', "Click mic to talk"); + : localize('voiceMode.clickMicHint', "Click voice mode to talk"); transcriptOverlay.append(hint); + transcriptScrollable.scanDomNode(); } else { - transcriptOverlay.style.display = 'none'; + transcriptOverlayNode.style.display = 'none'; + transcriptOverlayNode.classList.remove('has-transcript'); } return; } - transcriptOverlay.style.display = ''; + transcriptOverlayNode.style.display = ''; + transcriptOverlayNode.classList.add('has-transcript'); // Show only the latest turn: user question first, then assistant reply replaces it const lastTurn = visible[visible.length - 1]; const contentElements: HTMLElement[] = []; @@ -562,13 +564,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { div.textContent = lastTurn.text; contentElements.push(div); } - // Keep the style element, replace content - while (transcriptOverlay.childNodes.length > 1) { - transcriptOverlay.removeChild(transcriptOverlay.lastChild!); - } - for (const el of contentElements) { - transcriptOverlay.append(el); - } + transcriptOverlay.replaceChildren(...contentElements); + transcriptScrollable.scanDomNode(); + transcriptScrollable.setScrollPosition({ scrollTop: 0 }); })); } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css index c1415f8d2f0..49b08931e04 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css @@ -13,6 +13,63 @@ overflow: hidden; } +@keyframes voiceTextPulse { + 0%, + 100% { + opacity: 0.9; + } + + 50% { + opacity: 0.4; + } +} + +.voice-transcript-overlay-scrollable { + position: absolute !important; + top: 0; + left: 0; + right: 0; + bottom: 36px; + z-index: 10; + /* Let clicks pass through to the input editor unless there is scrollable transcript content */ + pointer-events: none; + background: var(--vscode-input-background, transparent); + border-radius: inherit; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.voice-transcript-overlay-scrollable.has-transcript { + pointer-events: auto; +} + +.voice-transcript-overlay { + height: 100%; + box-sizing: border-box; + padding: var(--vscode-spacing-size80) var(--vscode-spacing-size120); + font-size: var(--vscode-bodyFontSize); + line-height: 1.4; + word-break: break-word; + overflow: hidden; + overscroll-behavior: contain; +} + +.voice-transcript-overlay .committed { + color: var(--vscode-foreground); +} + +.voice-transcript-overlay .partial { + color: var(--vscode-foreground); + opacity: 0.6; + font-style: italic; + animation: voiceTextPulse 1.5s ease-in-out infinite; +} + +.voice-transcript-overlay .assistant-text { + color: var(--vscode-descriptionForeground); + white-space: pre-wrap; +} + /* Overall styles */ .chat-viewpane { display: flex;