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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 38 additions & 29 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1824,6 +1824,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
},
});
const { blocksSubmission: voiceInputActive, submitAfterDictation } = speechInput;
const voiceControlsOpen = speechInput.available && speechInput.state.phase !== "idle";
const submissionTarget = activePendingProgress ? "pending-user-input" : "provider-turn";
const submissionBlocked = isComposerSubmissionBlocked({
submissionTarget,
Expand Down Expand Up @@ -3569,13 +3570,35 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}
className="flex min-w-0 flex-1 flex-nowrap items-center justify-end gap-2"
>
<div
ref={attachExpandedComposerControls}
data-chat-composer-controls="footer"
data-chat-composer-footer-controls="true"
className="relative flex min-w-0 flex-1 items-center justify-end gap-0.5"
>
{composerControls}
<div className="relative flex min-w-0 flex-1 items-center">
<div
ref={attachExpandedComposerControls}
data-chat-composer-controls="footer"
data-chat-composer-footer-controls="true"
className={cn(
"relative flex min-w-0 flex-1 items-center justify-end gap-0.5 transition-[opacity,visibility] duration-150 ease-out",
voiceControlsOpen && "invisible opacity-0",
)}
>
{composerControls}
</div>
{/* Outside the measured controls so the timer's text changes never re-measure them. */}
{voiceControlsOpen ? (
<div className="absolute inset-0 flex items-center justify-end transition-[opacity,translate] duration-200 ease-(--ease-drawer) starting:translate-x-2 starting:opacity-0 motion-reduce:starting:translate-x-0">
<ComposerSpeechRecordingPill
state={speechInput.state}
progress={null}
finishShortcutLabel={dictationShortcutLabel}
cancelShortcutLabel="Esc"
level={speechInput.level}
disabled={dictationDisabled}
onStart={() => void speechInput.start()}
onStop={() => void speechInput.stop()}
onCancel={() => speechInput.cancel()}
onSkipPostProcessing={() => speechInput.skipPostProcessing()}
/>
</div>
) : null}
</div>
<ComposerFooterPrimaryActions
compact={isComposerPrimaryActionsCompact}
Expand Down Expand Up @@ -3618,28 +3641,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onToggleKeepFullHistory={props.onToggleKeepFullHistory}
>
{speechInput.available ? (
<>
<ComposerSpeechRecordingPill
state={speechInput.state}
progress={null}
finishShortcutLabel={dictationShortcutLabel}
cancelShortcutLabel="Esc"
level={speechInput.level}
disabled={dictationDisabled}
onStart={() => void speechInput.start()}
onStop={() => void speechInput.stop()}
onCancel={() => speechInput.cancel()}
onSkipPostProcessing={() => speechInput.skipPostProcessing()}
/>
<ComposerSpeechButton
state={speechInput.state}
progress={null}
shortcutLabel={dictationShortcutLabel}
disabled={dictationDisabled}
onStart={() => void speechInput.start()}
onCancel={() => speechInput.cancel()}
/>
</>
<ComposerSpeechButton
state={speechInput.state}
progress={null}
shortcutLabel={dictationShortcutLabel}
disabled={dictationDisabled}
onStart={() => void speechInput.start()}
onCancel={() => speechInput.cancel()}
/>
) : null}
<VoiceInputSetup
environmentId={speechInput.transcriptionEnvironmentId}
Expand Down
13 changes: 6 additions & 7 deletions apps/web/src/components/chat/ComposerSpeechButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@ function RecordingStatus(props: { level: number; recordingLimitSeconds?: number
return (
<>
<VoiceWaveform level={props.level} />
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-primary" />
<span className="shrink-0 text-secondary-label text-xs tabular-nums">
{formatElapsed(elapsedSeconds)}
{props.recordingLimitSeconds !== undefined
Expand All @@ -120,7 +119,7 @@ function ComposerSpeechStatus(props: {

return (
<div
className="flex h-7 min-w-0 flex-1 items-center gap-2"
className="flex h-7 min-w-0 flex-1 items-center justify-end gap-2"
role="status"
aria-live={isRecording ? "off" : "polite"}
aria-label={presentation.status}
Expand Down Expand Up @@ -250,17 +249,17 @@ export function ComposerSpeechRecordingPill(props: {

return (
<VoiceInputPill>
<ComposerSpeechCancelButton
state={props.state}
shortcutLabel={props.cancelShortcutLabel ?? null}
onCancel={props.onCancel}
/>
<ComposerSpeechStatus
state={props.state}
progress={props.progress}
level={props.level}
recordingLimitSeconds={props.recordingLimitSeconds}
/>
<ComposerSpeechCancelButton
state={props.state}
shortcutLabel={props.cancelShortcutLabel ?? null}
onCancel={props.onCancel}
/>
{props.state.phase === "post-processing" ? (
<Button
type="button"
Expand Down
18 changes: 8 additions & 10 deletions apps/web/src/components/chat/VoiceInputPill.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { memo, useEffect, useRef, type ReactNode } from "react";
import { useMediaQuery } from "~/hooks/useMediaQuery";

const WAVEFORM_BAR_COUNT = 28;
const WAVEFORM_BAR_COUNT = 12;
// A silent bar is a 2px dot: 2px of the bar's 14px height.
const WAVEFORM_MIN_SCALE = 2 / 14;
const WAVEFORM_BAR_IDS = Array.from({ length: WAVEFORM_BAR_COUNT }, (_, index) => String(index));

export const VoiceWaveform = memo(function VoiceWaveform(props: { level: number }) {
Expand All @@ -17,24 +19,20 @@ export const VoiceWaveform = memo(function VoiceWaveform(props: { level: number
barsRef.current.forEach((bar, index) => {
if (!bar) return;
const level = levels[index] ?? 0;
bar.style.opacity = String(0.22 + level * 0.78);
bar.style.transform = `scaleY(${prefersReducedMotion ? 0.35 : Math.max(0.08, level)})`;
bar.style.transform = `scaleY(${prefersReducedMotion ? 0.35 : Math.max(WAVEFORM_MIN_SCALE, level)})`;
});
}, [prefersReducedMotion, props.level]);

return (
<div
aria-hidden
className="flex h-5 min-w-0 flex-1 items-center justify-between gap-px overflow-hidden"
>
<div aria-hidden className="flex h-5 min-w-0 items-center justify-end gap-0.5 overflow-hidden">
{WAVEFORM_BAR_IDS.map((id, index) => (
<span
key={id}
ref={(bar) => {
barsRef.current[index] = bar;
}}
className="h-full w-0.5 shrink-0 origin-center rounded-full bg-primary opacity-25 transition-[transform,opacity] duration-100 ease-out motion-reduce:transition-none"
style={{ transform: `scaleY(${prefersReducedMotion ? 0.35 : 0.08})` }}
className="h-3.5 w-0.5 shrink-0 origin-center rounded-full bg-primary transition-transform duration-100 ease-out motion-reduce:transition-none"
style={{ transform: `scaleY(${prefersReducedMotion ? 0.35 : WAVEFORM_MIN_SCALE})` }}
/>
))}
</div>
Expand All @@ -46,7 +44,7 @@ export function VoiceInputPill({ children }: { children: ReactNode }) {
// Same height as the toolbar's icon buttons, so starting a recording never resizes the composer.
<div
data-slot="voice-input-pill"
className="flex h-8 w-48 min-w-0 shrink-0 items-center gap-2 sm:h-7 sm:w-64"
className="flex h-8 w-48 max-w-full min-w-0 shrink-0 items-center gap-2 sm:h-7 sm:w-64"
>
{children}
</div>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/settings/MicrophoneTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ export function MicrophoneTest({ microphoneId }: { microphoneId: string }) {
<Button
size="icon-sm-pill"
aria-label="Stop recording"
className="ms-auto"
disabled={phase !== "recording"}
onClick={() => {
const recorder = capture.current?.recorder;
Expand Down
Loading