From edf690b62bb744f43cd2be01c23feae1f8f67ca8 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 24 Jul 2026 19:55:58 -0400 Subject: [PATCH] chat: refine voice mode feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/LAYOUT.md | 2 ++ .../chat/browser/media/voiceChatView.css | 33 ++++--------------- .../chat/browser/voiceInputDecorations.ts | 4 +-- .../agentsVoice/browser/agentsVoiceWidget.ts | 8 ++--- .../chat/browser/voiceClient/voiceGlow.ts | 30 +++++------------ .../voiceInputMode/media/voiceInputMode.css | 18 ++++++---- .../voiceInputModeActionViewItem.ts | 2 +- .../chat/browser/widget/media/chat.css | 23 ++----------- .../widgetHosts/viewPane/chatViewPane.ts | 6 ++-- .../viewPane/media/chatViewPane.css | 6 +++- .../browser/voiceClient/voiceGlow.test.ts | 4 +-- 11 files changed, 48 insertions(+), 88 deletions(-) diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 653823164d5a9..1932efc7a2117 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -173,6 +173,8 @@ The `NewChatView` input uses the control-tier corner radius for its send button, `ChatView` mounts session input banners directly above the chat input. The CI failures banner uses the orange accent for the card border/icon and for the primary Fix Checks button background/border. +Voice mode uses the same compact segmented dictation/voice control and restrained audio-reactive input glow as the main chat surface. Instructional placeholder text stays static while live partial transcript text may pulse; the composer keeps the same keyboard, context-menu, and ARIA behavior as the shared control. + The shared chat input can show a transparent VS Code pet overlay above the composer. `/vscode-pet` toggles the persisted preference in active chats and the new-session composer. The state hooks for idle, sleeping, processing, confirmation, completion, and activation remain wired, but currently every state shows the same idle buddy: blue in Stable and green in Insiders/development builds. Active chats anchor the pet to the actual input row so confirmation and question widgets above it do not add spacing, while the new-session composer anchors it to its input-area wrapper. Cursor-tracked pupils render over eye-less derivative sprites so movement cannot expose the original baked-in eyes; the source PNG and GIF assets remain unchanged. Enabling makes the pet hop into place; disabling makes it duck away before its image source is unloaded. Both transitions are interruptible and skipped when reduced motion is enabled. Hovering the pet invites the user to show it some love and teases future interactions. When a `ChatView` loads its chat model (`acquireOrLoadSession`), it surfaces progress on **its own** progress bar, pinned to the top of that grid leaf. This mirrors how each editor group owns its `ProgressBar` (see `EditorGroupView`): the bar is created by the leaf host `AbstractChatView`, wrapped in a `ScopedProgressIndicator` (reused from `vs/workbench`) with an always-active scope, and driven via `AbstractChatView.showProgressWhile(promise, delay)`. Concurrent loads in other visible sessions each show their own progress instead of competing for a single part-wide bar, and overlapping loads on the same leaf are joined by the indicator so the bar only hides once all have settled. A short delay avoids flashing the bar for fast (cached) loads. diff --git a/src/vs/sessions/contrib/chat/browser/media/voiceChatView.css b/src/vs/sessions/contrib/chat/browser/media/voiceChatView.css index 6c9bf45c15749..6575ddccf5c2d 100644 --- a/src/vs/sessions/contrib/chat/browser/media/voiceChatView.css +++ b/src/vs/sessions/contrib/chat/browser/media/voiceChatView.css @@ -60,10 +60,14 @@ color: var(--vscode-foreground); } -.voice-transcript-overlay .partial { +.voice-transcript-overlay .partial, +.voice-transcript-overlay .hint { color: var(--vscode-foreground); opacity: 0.6; font-style: italic; +} + +.voice-transcript-overlay .partial { animation: sessionsVoiceTextPulse 1.5s ease-in-out infinite; } @@ -104,39 +108,16 @@ white-space: pre-wrap; } -/* Voice icon glow on the new-session composer toolbar. chat.css only targets the -chat widget's `.chat-input-container`; the composer uses `.new-chat-input-area`, -so mirror the blue-listening / purple-speaking glow here. `voice-active` / -`voice-listening` are toggled by setupVoiceInputDecorations. */ -@keyframes sessionsVoiceIconGlow { - 0%, - 100% { - box-shadow: 0 0 4px var(--sessions-voice-icon-glow-color); - } - - 50% { - box-shadow: 0 0 9px var(--sessions-voice-icon-glow-color); - } -} - /* Blue when listening */ .new-chat-input-area.voice-active.voice-listening .action-label.codicon-voice-mode { - --sessions-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 .new-chat-input-area.voice-active.voice-listening .action-label.codicon-voice-mode { - animation: sessionsVoiceIconGlow 1.4s ease-in-out infinite; + box-shadow: 0 0 3px rgba(88, 166, 255, 0.25); } /* Purple when speaking */ .new-chat-input-area.voice-active.voice-speaking .action-label.codicon-voice-mode { - --sessions-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 .new-chat-input-area.voice-active.voice-speaking .action-label.codicon-voice-mode { - animation: sessionsVoiceIconGlow 1.4s ease-in-out infinite; + box-shadow: 0 0 3px rgba(163, 113, 247, 0.25); } diff --git a/src/vs/sessions/contrib/chat/browser/voiceInputDecorations.ts b/src/vs/sessions/contrib/chat/browser/voiceInputDecorations.ts index 57ba4894e3075..ae77ff43c676f 100644 --- a/src/vs/sessions/contrib/chat/browser/voiceInputDecorations.ts +++ b/src/vs/sessions/contrib/chat/browser/voiceInputDecorations.ts @@ -149,7 +149,7 @@ export function setupVoiceInputDecorations(services: IVoiceInputDecorationsServi transcriptOverlayNode.style.display = ''; transcriptOverlayNode.classList.remove('has-transcript'); transcriptOverlay.replaceChildren(); - const hint = dom.$('span.partial'); + const hint = dom.$('span.hint'); const kb = keybindingService.lookupKeybinding('agentsVoice.pushToTalk'); const kbLabel = kb?.getLabel(); hint.textContent = kbLabel @@ -161,7 +161,7 @@ export function setupVoiceInputDecorations(services: IVoiceInputDecorationsServi transcriptOverlayNode.style.display = ''; transcriptOverlayNode.classList.remove('has-transcript'); transcriptOverlay.replaceChildren(); - const hint = dom.$('span.partial'); + const hint = dom.$('span.hint'); const kb = keybindingService.lookupKeybinding('agentsVoice.pushToTalk'); const kbLabel = kb?.getLabel(); hint.textContent = kbLabel diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts index fc5f2dd052b03..647a03c706c73 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts @@ -1085,8 +1085,8 @@ export class AgentsVoiceWidget extends Disposable { if (this._inputBoxMicBtn) { const iconGlowActive = voiceState === 'listening' || voiceState === 'speaking'; if (iconGlowActive) { - const shadowSpread = 3 + intensity * 8; - const shadowAlpha = 0.2 + intensity * 0.45; + const shadowSpread = 2 + intensity * 4; + const shadowAlpha = 0.06 + intensity * 0.12; const glowColor = `rgba(${voiceState === 'speaking' ? '163,113,247' : '88,166,255'},${shadowAlpha})`; this._inputBoxMicBtn.style.boxShadow = `0 0 ${shadowSpread}px ${glowColor}`; } else { @@ -1096,9 +1096,9 @@ export class AgentsVoiceWidget extends Disposable { // Classic layout glow div this._glowDiv.style.display = ''; - const baseOpacity = 0.15 + intensity * 0.4; + const baseOpacity = 0.06 + intensity * 0.12; const r = (onboarding || voiceState === 'speaking') ? '163,113,247' : '88,166,255'; - this._glowDiv.style.background = `radial-gradient(ellipse 40% 70% at 50% 0%, rgba(${r},${baseOpacity}) 0%, transparent 100%), radial-gradient(ellipse 70% 100% at 50% 0%, rgba(${r},${baseOpacity * 0.4}) 0%, transparent 100%)`; + this._glowDiv.style.background = `radial-gradient(ellipse 40% 70% at 50% 0%, rgba(${r},${baseOpacity}) 0%, transparent 100%)`; }; this._animationFrameId = getWindow(this.container).requestAnimationFrame(animate); } diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceGlow.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceGlow.ts index e20ec997d23a3..d38577455552e 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceGlow.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceGlow.ts @@ -47,32 +47,18 @@ export interface IVoiceGlowStyle { } /** - * Compute the glow border color and box-shadow. Blue while listening (flashier - * when the transcript is hidden) and purple while speaking. Connected-idle voice - * mode renders no glow and never reaches this function. + * Compute the glow border color and box-shadow. Blue while listening and purple + * while speaking. Connected-idle voice mode renders no glow and never reaches + * this function. */ export function computeVoiceGlowStyle(voiceState: VoiceGlowState, intensity: number, transcriptHidden: boolean): IVoiceGlowStyle { // Blue when listening, purple when speaking. const rgb = voiceState === 'speaking' ? '163,113,247' : '88,166,255'; - const flashy = voiceState === 'listening' && transcriptHidden; - let borderAlpha: number; - let shadowSpread: number; - let shadowAlpha: number; - if (flashy) { - // Flashy audio-reactive glow while speaking with no transcript visible. - borderAlpha = 0.6 + intensity * 0.4; - shadowSpread = 6 + intensity * 20; - shadowAlpha = 0.25 + intensity * 0.55; - } else { - // Standard glow (transcript visible or TTS playback). - borderAlpha = 0.4 + intensity * 0.5; - shadowSpread = 4 + intensity * 12; - shadowAlpha = 0.15 + intensity * 0.35; - } + const emphasized = voiceState === 'listening' && transcriptHidden; + const borderAlpha = (emphasized ? 0.35 : 0.3) + intensity * (emphasized ? 0.25 : 0.2); + const shadowSpread = (emphasized ? 3 : 2) + intensity * (emphasized ? 6 : 4); + const shadowAlpha = (emphasized ? 0.08 : 0.06) + intensity * (emphasized ? 0.14 : 0.12); const borderColor = `rgba(${rgb},${borderAlpha})`; - const boxShadow = flashy - // Double-layer glow for extra presence when listening without transcript. - ? `0 0 ${shadowSpread}px rgba(${rgb},${shadowAlpha}), 0 0 ${shadowSpread * 2}px rgba(${rgb},${shadowAlpha * 0.3}), inset 0 0 ${shadowSpread * 0.5}px rgba(${rgb},${shadowAlpha * 0.4})` - : `0 0 ${shadowSpread}px rgba(${rgb},${shadowAlpha}), inset 0 0 ${shadowSpread * 0.4}px rgba(${rgb},${shadowAlpha * 0.3})`; + const boxShadow = `0 0 ${shadowSpread}px rgba(${rgb},${shadowAlpha})`; return { borderColor, boxShadow }; } diff --git a/src/vs/workbench/contrib/chat/browser/voiceInputMode/media/voiceInputMode.css b/src/vs/workbench/contrib/chat/browser/voiceInputMode/media/voiceInputMode.css index 4c5587776b22e..02fe343ecb2c7 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceInputMode/media/voiceInputMode.css +++ b/src/vs/workbench/contrib/chat/browser/voiceInputMode/media/voiceInputMode.css @@ -32,8 +32,14 @@ color: var(--vscode-charts-purple, #a371f7); } +.chat-voice-input-mode { + --segmented-icon-toggle-width: 50px; + --segmented-icon-toggle-cell-width: 24px; + --segmented-icon-toggle-single-width: 48px; +} + .chat-voice-input-mode-icon { - font-size: 16px; + font-size: var(--vscode-codiconFontSize-compact); } /* The Device EQ waveform lives in the voice cell and transforms per state. Thin bars = default (disconnected); thick bars = filled (connected). */ @@ -41,7 +47,7 @@ display: inline-flex; align-items: center; gap: 2px; - height: 16px; + height: 12px; } .chat-voice-input-mode-bar { @@ -54,9 +60,9 @@ /* Device EQ silhouette: symmetric center-peak (matches Device EQ.svg). Bars stay thin in every state — connected reads via a darker color, not thicker bars. */ .chat-voice-input-mode-bar:nth-child(1) { height: 4px; } -.chat-voice-input-mode-bar:nth-child(2) { height: 8px; } -.chat-voice-input-mode-bar:nth-child(3) { height: 12px; } -.chat-voice-input-mode-bar:nth-child(4) { height: 8px; } +.chat-voice-input-mode-bar:nth-child(2) { height: 6px; } +.chat-voice-input-mode-bar:nth-child(3) { height: 10px; } +.chat-voice-input-mode-bar:nth-child(4) { height: 6px; } .chat-voice-input-mode-bar:nth-child(5) { height: 4px; } /* Hover while connected → preview disconnect: collapse to a short, even, "silent" row (no waveform, no motion). The height transition makes leaving the hover grow the bars smoothly back into the active waveform. */ @@ -100,5 +106,5 @@ @keyframes chat-voice-input-mode-eq { 0%, 100% { height: 3px; } - 50% { height: 13px; } + 50% { height: 11px; } } diff --git a/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts b/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts index 034165a427eb5..392f56215085e 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts @@ -589,7 +589,7 @@ export class VoiceInputModeActionViewItem extends BaseActionViewItem { sum += this._barData[Math.min(bins - 1, i * step + j)]; } const intensity = Math.min(1, (sum / step) / 180); - const heightPx = 3 + intensity * 11; + const heightPx = 3 + intensity * 8; // Disable the CSS keyframe fallback while we drive heights from live audio. this._voiceBarEls[i].style.animation = 'none'; this._voiceBarEls[i].style.height = `${heightPx}px`; 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 1afe09379c914..a57b8e9601a46 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -4520,37 +4520,18 @@ have to be updated for changes to the rules above, or to support more deeply nes outline: none !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); - } -} - /* 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; + box-shadow: 0 0 3px rgba(88, 166, 255, 0.25); } /* Voice mode: purple when speaking */ .chat-input-container.voice-active.voice-speaking .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.voice-speaking .chat-input-toolbars .action-label.codicon-voice-mode { - animation: chat-voice-icon-glow 1.4s ease-in-out infinite; + box-shadow: 0 0 3px rgba(163, 113, 247, 0.25); } /* 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 3c1c32bb33449..5aa1432efc91a 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -576,7 +576,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { transcriptOverlayNode.style.display = ''; transcriptOverlayNode.classList.remove('has-transcript'); transcriptOverlay.replaceChildren(); - const hint = $('span.partial'); + const hint = $('span.hint'); switch (simVersion) { case 'handsFree': hint.textContent = localize('voiceMode.simHint.handsFree', "Hands-free \u2014 just start talking"); @@ -678,7 +678,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { transcriptOverlayNode.style.display = ''; transcriptOverlayNode.classList.remove('has-transcript'); transcriptOverlay.replaceChildren(); - const hint = $('span.partial'); + const hint = $('span.hint'); const kb = this.keybindingService.lookupKeybinding('workbench.action.chat.voiceInputMode.holdToTalk') ?? this.keybindingService.lookupKeybinding('agentsVoice.pushToTalk'); const kbLabel = kb?.getLabel(); @@ -691,7 +691,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { transcriptOverlayNode.style.display = ''; transcriptOverlayNode.classList.remove('has-transcript'); transcriptOverlay.replaceChildren(); - const hint = $('span.partial'); + const hint = $('span.hint'); const kb = this.keybindingService.lookupKeybinding('agentsVoice.pushToTalk'); const kbLabel = kb?.getLabel(); hint.textContent = kbLabel 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 7824564728c6e..3ee5b09555db0 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 @@ -68,10 +68,14 @@ color: var(--vscode-foreground); } -.voice-transcript-overlay .partial { +.voice-transcript-overlay .partial, +.voice-transcript-overlay .hint { color: var(--vscode-foreground); opacity: 0.6; font-style: italic; +} + +.voice-transcript-overlay .partial { animation: voiceTextPulse 1.5s ease-in-out infinite; } diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceGlow.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceGlow.test.ts index 99a693b961cd7..8506370cbee45 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceGlow.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceGlow.test.ts @@ -18,8 +18,8 @@ suite('VoiceGlow', () => { boxShadow: listeningStyle.boxShadow, }, { - borderColor: 'rgba(88,166,255,0.65)', - boxShadow: '0 0 10px rgba(88,166,255,0.32499999999999996), inset 0 0 4px rgba(88,166,255,0.09749999999999999)' + borderColor: 'rgba(88,166,255,0.4)', + boxShadow: '0 0 4px rgba(88,166,255,0.12)' } ); });