mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-09 00:05:28 +01:00
voice mode: enable transcript scrolling in chat input and aux window (#322390)
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 });
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user