refactor: update chat ui (#13269)

### What problem does this PR solve?

Update **Chat** UI:
- Align to the design.
- Update `<AudioButton>` visualizer logic.
- Fix keyboard navigation issue.

### Type of change

- [x] Refactoring
This commit is contained in:
Jimmy Ben Klieve
2026-02-27 22:26:19 +08:00
committed by GitHub
parent 4e48aba5c4
commit c0823e8d6d
23 changed files with 785 additions and 602 deletions

View File

@@ -5,11 +5,13 @@ import { Authorization } from '@/constants/authorization';
import { cn } from '@/lib/utils';
import api from '@/utils/api';
import { getAuthorization } from '@/utils/authorization-util';
import { chain, sum } from 'lodash';
import { Loader2, Mic, Square } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useIsDarkTheme } from '../theme-provider';
import { Input } from './input';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
const VoiceVisualizer = ({ isRecording }: { isRecording: boolean }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const audioContextRef = useRef<AudioContext | null>(null);
@@ -18,7 +20,79 @@ const VoiceVisualizer = ({ isRecording }: { isRecording: boolean }) => {
const streamRef = useRef<MediaStream | null>(null);
const isDark = useIsDarkTheme();
const startVisualization = async () => {
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const analyser = analyserRef.current;
if (!analyser) return;
// Set canvas dimensions
const width = canvas.clientWidth * window.devicePixelRatio;
const height = canvas.clientHeight * window.devicePixelRatio;
const centerY = height / 2;
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Get frequency data
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
/**
* The frequencies are spread linearly from 0Hz to 1/2 of the sample rate
* @see https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/getByteFrequencyData
*/
analyser.getByteFrequencyData(dataArray);
const desiredBarCount = 6;
const freqSpanPerPole =
analyser.context.sampleRate / 2 / analyser.frequencyBinCount;
const freqMinIndex = Math.max(1, Math.floor(100 / freqSpanPerPole));
const freqMaxIndex =
Math.max(freqMinIndex, Math.ceil(3500 / freqSpanPerPole)) + 1;
const freqData = dataArray.slice(
freqMinIndex,
Math.max(freqMinIndex + desiredBarCount, freqMaxIndex),
);
const reducedFreqData = chain(freqData)
.chunk(Math.floor(freqData.length / desiredBarCount))
.take(desiredBarCount)
.map(sum)
.value();
// Draw waveform
const barGap = 1 * window.devicePixelRatio;
const barWidth = (width - barGap * (desiredBarCount - 1)) / desiredBarCount;
// Create gradient
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, '#3ba05c'); // Blue
gradient.addColorStop(1, '#3ba05c'); // Light blue
// gradient.addColorStop(0, isDark ? '#fff' : '#000'); // Blue
// gradient.addColorStop(1, isDark ? '#eee' : '#eee'); // Light blue
for (let i = 0, x = 0; i < desiredBarCount; i++, x += barWidth + barGap) {
const barHeight = (reducedFreqData[i] / 255) * centerY;
ctx.fillStyle = gradient;
ctx.fillRect(x, centerY - barHeight, barWidth, barHeight * 2);
}
animationFrameRef.current = requestAnimationFrame(draw);
}, []);
const startVisualization = useCallback(async () => {
try {
// Check if the browser supports getUserMedia
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
@@ -30,13 +104,14 @@ const VoiceVisualizer = ({ isRecording }: { isRecording: boolean }) => {
streamRef.current = stream;
// Create audio context and analyzer
const audioContext = new (window.AudioContext ||
(window as any).webkitAudioContext)();
const audioContext = new (
window.AudioContext || (window as any).webkitAudioContext
)();
audioContextRef.current = audioContext;
const analyser = audioContext.createAnalyser();
analyserRef.current = analyser;
analyser.fftSize = 32;
analyser.fftSize = 256;
// Connect audio nodes
const source = audioContext.createMediaStreamSource(stream);
@@ -50,9 +125,9 @@ const VoiceVisualizer = ({ isRecording }: { isRecording: boolean }) => {
error,
);
}
};
}, [draw]);
const stopVisualization = () => {
const stopVisualization = useCallback(() => {
// Stop animation frame
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
@@ -76,7 +151,8 @@ const VoiceVisualizer = ({ isRecording }: { isRecording: boolean }) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}
};
}, []);
useEffect(() => {
if (isRecording) {
startVisualization();
@@ -87,68 +163,9 @@ const VoiceVisualizer = ({ isRecording }: { isRecording: boolean }) => {
return () => {
stopVisualization();
};
}, [isRecording]);
const draw = () => {
const canvas = canvasRef.current;
if (!canvas) return;
}, [isRecording, startVisualization, stopVisualization]);
const ctx = canvas.getContext('2d');
if (!ctx) return;
const analyser = analyserRef.current;
if (!analyser) return;
// Set canvas dimensions
const width = canvas.clientWidth;
const height = canvas.clientHeight;
const centerY = height / 2;
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Get frequency data
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
analyser.getByteFrequencyData(dataArray);
// Draw waveform
const barWidth = (width / bufferLength) * 1.5;
let x = 0;
for (let i = 0; i < bufferLength; i = i + 2) {
const barHeight = (dataArray[i] / 255) * centerY;
// Create gradient
const gradient = ctx.createLinearGradient(
0,
centerY - barHeight,
0,
centerY + barHeight,
);
gradient.addColorStop(0, '#3ba05c'); // Blue
gradient.addColorStop(1, '#3ba05c'); // Light blue
// gradient.addColorStop(0, isDark ? '#fff' : '#000'); // Blue
// gradient.addColorStop(1, isDark ? '#eee' : '#eee'); // Light blue
ctx.fillStyle = gradient;
ctx.fillRect(x, centerY - barHeight, barWidth, barHeight * 2);
x += barWidth + 2;
}
animationFrameRef.current = requestAnimationFrame(draw);
};
return (
<div className="w-full h-6 bg-transparent flex items-center justify-center overflow-hidden ">
<canvas ref={canvasRef} className="w-full h-full" />
</div>
);
return <canvas ref={canvasRef} className="block size-full" />;
};
const VoiceInputBox = ({
@@ -363,21 +380,22 @@ export const AudioButton = ({
<div className=" relative w-6 h-6 flex items-center justify-center">
{isRecording && (
<div
className={cn(
'absolute inset-0 w-full h-6 rounded-full overflow-hidden flex items-center justify-center p-1',
{ 'bg-state-success-5': isRecording },
)}
>
<div className="absolute inset-0 size-full overflow-hidden flex items-center justify-center p-1">
<VoiceVisualizer isRecording={isRecording} />
</div>
)}
{isRecording && (
<div className="absolute inset-0 rounded-full border-2 border-state-success animate-ping opacity-75"></div>
<div
className="
absolute inset-0 rounded-full border-2 border-state-success
animate-ping duration-[4s] opacity-75 pointer-events-none"
/>
)}
<Button
variant="outline"
size="sm"
variant="transparent"
size="icon-xs"
// onMouseDown={() => {
// startRecording();
// }}
@@ -391,11 +409,11 @@ export const AudioButton = ({
startRecording();
}
}}
className={`w-6 h-6 p-2 rounded-md border-none bg-transparent hover:bg-state-success-5 ${
isRecording
? 'animate-pulse bg-state-success-5 text-state-success'
: ''
}`}
className={cn(
'border-0 p-2 rounded-md border-none bg-transparent hover:bg-state-success-5',
isRecording &&
'animate-pulse !bg-state-success/20 text-state-success rounded-full',
)}
disabled={isProcessing}
>
{isProcessing ? (

View File

@@ -53,7 +53,10 @@ const BreadcrumbLink = React.forwardRef<
return (
<Comp
ref={ref}
className={cn('transition-colors hover:text-foreground', className)}
className={cn(
'transition-colors hover:text-foreground focus-visible:text-foreground',
className,
)}
{...props}
/>
);

View File

@@ -0,0 +1,83 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
const buttonGroupVariants = cva(
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
vertical:
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
},
},
defaultVariants: {
orientation: 'horizontal',
},
},
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'div';
return (
<Comp
className={cn(
"bg-muted shadow-xs flex items-center gap-2 rounded-md border px-4 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className,
)}
{...props}
/>
);
}
function ButtonGroupSeparator({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
className,
)}
{...props}
/>
);
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
};

View File

@@ -36,7 +36,7 @@ const buttonVariants = cva(
link: 'text-primary underline-offset-4 hover:underline',
icon: 'bg-colors-background-inverse-standard text-foreground hover:bg-colors-background-inverse-standard/80',
dashed: 'border border-dashed border-input hover:bg-accent',
dashed: 'border border-dashed border-border-button hover:bg-accent',
transparent: `
text-text-secondary bg-transparent border-0.5 border-border-button
@@ -49,10 +49,16 @@ const buttonVariants = cva(
hover:bg-state-error/10 focus-visible:bg-state-error/10
`,
accent: `
bg-accent-primary text-white
hover:bg-accent-primary/90 focus-visible:bg-accent-primary/90
`,
highlighted: `
bg-text-primary text-bg-base border-b-4 border-b-accent-primary
hover:bg-text-primary/90 focus-visible:bg-text-primary/90
`,
delete: `
text-text-secondary
hover:bg-state-error-5 hover:text-state-error
@@ -65,6 +71,8 @@ const buttonVariants = cva(
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
auto: 'h-full px-1',
'icon-sm': 'size-8',
'icon-xs': 'size-7',
},
},
defaultVariants: {
@@ -75,7 +83,8 @@ const buttonVariants = cva(
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
loading?: boolean;
@@ -126,7 +135,7 @@ ButtonLoading.displayName = 'ButtonLoading';
export { Button, buttonVariants };
export const BlockButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ children, className, ...props }, ref) => {
function BlockButton({ children, className, ...props }, ref) {
return (
<Button
variant={'outline'}

View File

@@ -5,8 +5,9 @@ import * as React from 'react';
import { cn } from '@/lib/utils';
interface DualRangeSliderProps
extends React.ComponentProps<typeof SliderPrimitive.Root> {
interface DualRangeSliderProps extends React.ComponentProps<
typeof SliderPrimitive.Root
> {
labelPosition?: 'top' | 'bottom';
label?: (value: number | undefined) => React.ReactNode;
}
@@ -33,7 +34,12 @@ const DualRangeSlider = React.forwardRef<
</SliderPrimitive.Track>
{initialValue.map((value, index) => (
<React.Fragment key={index}>
<SliderPrimitive.Thumb className="relative block h-2.5 w-2.5 rounded-full border-2 border-accent-primary bg-white ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer">
<SliderPrimitive.Thumb
className="
relative block h-2.5 w-2.5 rounded-full border-2 border-accent-primary bg-white ring-offset-background transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50 cursor-pointer"
>
{label && (
<span
className={cn(

View File

@@ -20,7 +20,13 @@ const Slider = React.forwardRef<
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-colors-background-inverse-strong">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-colors-text-core-standard ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb
className="
block h-5 w-5 rounded-full border-2 border-primary bg-colors-text-core-standard transition-colors ring-offset-background
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50"
/>
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
@@ -33,16 +39,18 @@ type SliderProps = Omit<
const FormSlider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
SliderProps
>(({ onChange, value, ...props }, ref) => (
<Slider
ref={ref}
{...props}
value={[value]}
onValueChange={(vals) => {
onChange(vals[0]);
}}
></Slider>
));
>(function FormSlider({ onChange, value, ...props }, ref) {
return (
<Slider
ref={ref}
{...props}
value={[value]}
onValueChange={(vals) => {
onChange(vals[0]);
}}
/>
);
});
Slider.displayName = SliderPrimitive.Root.displayName;