voice mode: enable transcript scrolling in chat input and aux window (#322390)

This commit is contained in:
Megan Rogge
2026-06-22 14:12:48 -04:00
committed by GitHub
parent 49d32125de
commit 7a9cd87b8b
10 changed files with 200 additions and 87 deletions

View File

@@ -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 '<name>' 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 '<name>' 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)

View File

@@ -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",

View File

@@ -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),

View File

@@ -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")
};

View File

@@ -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: '<angle>'; 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;

View File

@@ -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;

View File

@@ -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);
});
}

View File

@@ -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 */

View File

@@ -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 });
}));
}

View File

@@ -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;