Enhance AssistantPage with subtle scrollbar styling and improve voice preview handling

- Introduce a new utility class `.scrollbar-subtle` in `globals.css` for a refined scrollbar appearance.
- Update `AssistantPage` to apply the subtle scrollbar styling to overflow containers, enhancing the user interface.
- Reset message state and sequence in `useVoicePreview` hook upon resource release, ensuring clean state management.
This commit is contained in:
Xin Wang
2026-07-07 10:07:36 +08:00
parent e857828fe5
commit 051a5dd51b
3 changed files with 287 additions and 206 deletions

View File

@@ -232,3 +232,43 @@
text-transform: uppercase; text-transform: uppercase;
} }
} }
@layer utilities {
.scrollbar-subtle {
scrollbar-width: thin;
scrollbar-color: color-mix(
in srgb,
var(--muted-soft) 48%,
transparent
)
transparent;
}
.scrollbar-subtle::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.scrollbar-subtle::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-subtle::-webkit-scrollbar-thumb {
background-color: color-mix(
in srgb,
var(--muted-soft) 42%,
transparent
);
border: 3px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
.scrollbar-subtle::-webkit-scrollbar-thumb:hover {
background-color: color-mix(
in srgb,
var(--muted-soft) 64%,
transparent
);
}
}

View File

@@ -33,7 +33,6 @@ import {
Waves, Waves,
Bug, Bug,
Video, Video,
SlidersHorizontal,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -1321,7 +1320,7 @@ export function AssistantPage(props: AssistantPageProps) {
</div> </div>
<div className="flex min-h-0 flex-1 gap-6"> <div className="flex min-h-0 flex-1 gap-6">
<div className="min-w-0 flex-1 space-y-5 overflow-y-auto pr-1"> <div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
<SectionCard <SectionCard
icon={<Boxes size={18} />} icon={<Boxes size={18} />}
title="Dify 应用配置" title="Dify 应用配置"
@@ -1420,7 +1419,7 @@ export function AssistantPage(props: AssistantPageProps) {
</div> </div>
<div className="flex min-h-0 flex-1 gap-6"> <div className="flex min-h-0 flex-1 gap-6">
<div className="min-w-0 flex-1 space-y-5 overflow-y-auto pr-1"> <div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
<SectionCard <SectionCard
icon={<Database size={18} />} icon={<Database size={18} />}
title="FastGPT 应用配置" title="FastGPT 应用配置"
@@ -1534,7 +1533,7 @@ export function AssistantPage(props: AssistantPageProps) {
</div> </div>
<div className="flex min-h-0 flex-1 gap-6"> <div className="flex min-h-0 flex-1 gap-6">
<div className="min-w-0 flex-1 space-y-5 overflow-y-auto pr-1"> <div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
<SectionCard <SectionCard
icon={<Terminal size={18} />} icon={<Terminal size={18} />}
title="OpenCode 服务配置" title="OpenCode 服务配置"
@@ -1652,7 +1651,7 @@ export function AssistantPage(props: AssistantPageProps) {
</div> </div>
<div className="flex min-h-0 flex-1 gap-6"> <div className="flex min-h-0 flex-1 gap-6">
<div className="min-w-0 flex-1 space-y-5 overflow-y-auto pr-1"> <div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
<SectionCard> <SectionCard>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div <div
@@ -1727,8 +1726,9 @@ export function AssistantPage(props: AssistantPageProps) {
</div> </div>
<div className="mt-4 border-t border-hairline pt-4"> <div className="mt-4 border-t border-hairline pt-4">
<ToggleRow <ToggleRow
icon={<Eye size={16} />}
title="视觉理解" title="视觉理解"
description="开启后,右侧调试面板可切换到视频流预览,并可同时选择麦克风与摄像头" hint="开启后,右侧调试面板可切换到视频流预览,并可同时选择麦克风与摄像头"
checked={visionEnabled} checked={visionEnabled}
onChange={setVisionEnabled} onChange={setVisionEnabled}
/> />
@@ -1841,6 +1841,7 @@ type VizStyle = "aura" | "nebula" | "bars" | "wave";
// 调试面板顶部主视图:聊天记录 / 视频流(仅视觉理解开启时可选) // 调试面板顶部主视图:聊天记录 / 视频流(仅视觉理解开启时可选)
type DebugView = "chat" | "video"; type DebugView = "chat" | "video";
type DebugInputMode = "mic" | "text";
const VIZ_OPTIONS: { style: VizStyle; label: string; icon: React.ReactNode }[] = const VIZ_OPTIONS: { style: VizStyle; label: string; icon: React.ReactNode }[] =
[ [
@@ -1917,38 +1918,29 @@ function DebugDrawer({
const camera = useCameraPreview(); const camera = useCameraPreview();
const [showTranscript, setShowTranscript] = useState(false); const [showTranscript, setShowTranscript] = useState(false);
const [vizStyle, setVizStyle] = useState<VizStyle>("aura"); const [vizStyle, setVizStyle] = useState<VizStyle>("aura");
const [view, setView] = useState<DebugView>(vision ? "video" : "chat"); const [view, setView] = useState<DebugView>("chat");
const effectiveView: DebugView = vision ? view : "chat"; const effectiveView: DebugView = vision ? view : "chat";
const stopCamera = camera.stop; const stopCamera = camera.stop;
const startCamera = camera.start;
const cameraShouldRun =
vision &&
(preview.status === "connecting" || preview.status === "connected");
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect if (!vision) {
setView(vision ? "video" : "chat"); // eslint-disable-next-line react-hooks/set-state-in-effect
if (!vision) stopCamera(); setView("chat");
stopCamera();
}
}, [vision, stopCamera]); }, [vision, stopCamera]);
const { start: startCamera, active: cameraActive, starting: cameraStarting } =
camera;
useEffect(() => { useEffect(() => {
if ( if (cameraShouldRun) {
vision &&
effectiveView === "video" &&
!cameraActive &&
!cameraStarting
) {
void startCamera(); void startCamera();
} else {
stopCamera();
} }
}, [ }, [cameraShouldRun, camera.deviceId, startCamera, stopCamera]);
vision,
effectiveView,
cameraActive,
cameraStarting,
startCamera,
]);
useEffect(() => {
if (effectiveView !== "video") stopCamera();
}, [effectiveView, stopCamera]);
const containerClass = asSheet const containerClass = asSheet
? "flex h-full min-w-0 flex-1 flex-col overflow-hidden" ? "flex h-full min-w-0 flex-1 flex-col overflow-hidden"
@@ -2001,7 +1993,6 @@ function DebugDrawer({
</SegmentedIconGroup> </SegmentedIconGroup>
</> </>
)} )}
<DeviceMenu preview={preview} camera={camera} vision={vision} />
<SegmentedIconGroup label="预览视图"> <SegmentedIconGroup label="预览视图">
<SegmentedIconButton <SegmentedIconButton
selected={effectiveView === "chat"} selected={effectiveView === "chat"}
@@ -2022,7 +2013,13 @@ function DebugDrawer({
</SegmentedIconGroup> </SegmentedIconGroup>
</div> </div>
</div> </div>
{vision && (
<div className="shrink-0 border-b border-hairline px-5 py-2.5">
<div className="flex h-10 min-w-0 items-center rounded-[1.4rem] border border-hairline-strong bg-background px-2">
<CameraDeviceField camera={camera} />
</div>
</div>
)}
<DebugVoicePanel <DebugVoicePanel
view={effectiveView} view={effectiveView}
showTranscript={showTranscript} showTranscript={showTranscript}
@@ -2030,75 +2027,15 @@ function DebugDrawer({
assistantId={assistantId} assistantId={assistantId}
preview={preview} preview={preview}
camera={camera} camera={camera}
vision={vision}
/> />
</aside> </aside>
); );
} }
function DeviceMenu({ function DeviceSelectField({
preview,
camera,
vision,
}: {
preview: VoicePreview;
camera: CameraPreview;
vision: boolean;
}) {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label="设备设置"
title="设备设置"
className="flex h-8 items-center gap-1.5 rounded-full border border-hairline bg-canvas-soft px-2.5 text-xs text-muted-soft transition-colors hover:text-foreground"
>
<SlidersHorizontal size={14} />
<span className="hidden sm:inline"></span>
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 p-0">
<div className="border-b border-hairline px-3 py-2.5">
<div className="text-xs font-medium text-foreground"></div>
<p className="mt-0.5 text-[11px] leading-4 text-muted-foreground">
{vision
? "选择麦克风与摄像头,分别用于语音与视觉预览。"
: "选择麦克风用于语音对话。"}
</p>
</div>
<div className="space-y-3 p-3">
<DeviceSelectRow
icon={<Mic size={13} className="shrink-0 text-muted-soft" />}
label="麦克风"
placeholder="默认麦克风"
fallbackLabel="麦克风"
value={preview.selectedDeviceId}
devices={preview.audioInputs}
onSelect={(id) => preview.selectDevice(id)}
/>
{vision && (
<>
<div className="border-t border-hairline" />
<DeviceSelectRow
icon={<Video size={13} className="shrink-0 text-muted-soft" />}
label="摄像头"
placeholder="默认摄像头"
fallbackLabel="摄像头"
value={camera.deviceId}
devices={camera.devices}
onSelect={(id) => void camera.selectCamera(id)}
/>
</>
)}
</div>
</PopoverContent>
</Popover>
);
}
function DeviceSelectRow({
icon, icon,
label, ariaLabel,
placeholder, placeholder,
fallbackLabel, fallbackLabel,
value, value,
@@ -2106,7 +2043,7 @@ function DeviceSelectRow({
onSelect, onSelect,
}: { }: {
icon: React.ReactNode; icon: React.ReactNode;
label: string; ariaLabel: string;
placeholder: string; placeholder: string;
fallbackLabel: string; fallbackLabel: string;
value: string; value: string;
@@ -2114,30 +2051,96 @@ function DeviceSelectRow({
onSelect: (deviceId: string) => void; onSelect: (deviceId: string) => void;
}) { }) {
return ( return (
<div className="space-y-1.5"> <Select
<div className="text-xs font-medium text-muted-foreground">{label}</div> value={value || "default"}
<Select onValueChange={(nextValue) =>
value={value || "default"} onSelect(nextValue === "default" ? "" : nextValue)
onValueChange={(v) => onSelect(v === "default" ? "" : v)} }
>
<SelectTrigger
size="sm"
className="h-10 min-w-0 flex-1 justify-between rounded-full border-transparent bg-transparent px-3 text-sm text-foreground shadow-none hover:bg-surface-strong focus-visible:ring-0 data-[size=sm]:h-10"
aria-label={ariaLabel}
> >
<SelectTrigger <span className="flex min-w-0 items-center gap-2">
size="sm"
className="w-full gap-2 rounded-full border-hairline bg-canvas-soft text-xs text-muted-foreground"
aria-label={`选择${label}`}
>
{icon} {icon}
<SelectValue placeholder={placeholder} /> <span className="min-w-0 truncate text-left">
</SelectTrigger> <SelectValue placeholder={placeholder} />
<SelectContent> </span>
<SelectItem value="default">{placeholder}</SelectItem> </span>
{devices.map((device, index) => ( </SelectTrigger>
<SelectItem key={device.deviceId} value={device.deviceId}> <SelectContent
{device.label || `${fallbackLabel} ${index + 1}`} position="popper"
</SelectItem> side="top"
))} align="start"
</SelectContent> className="w-[280px]"
</Select> >
</div> <SelectItem value="default">{placeholder}</SelectItem>
{devices.map((device, index) => (
<SelectItem key={device.deviceId} value={device.deviceId}>
{device.label || `${fallbackLabel} ${index + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function MicrophoneDeviceField({ preview }: { preview: VoicePreview }) {
return (
<DeviceSelectField
icon={<Mic size={15} className="shrink-0 text-muted-soft" />}
ariaLabel="选择麦克风"
placeholder="默认麦克风"
fallbackLabel="麦克风"
value={preview.selectedDeviceId}
devices={preview.audioInputs}
onSelect={(deviceId) => preview.selectDevice(deviceId)}
/>
);
}
function CameraDeviceField({ camera }: { camera: CameraPreview }) {
return (
<DeviceSelectField
icon={<Video size={15} className="shrink-0 text-muted-soft" />}
ariaLabel="选择摄像头"
placeholder="默认摄像头"
fallbackLabel="摄像头"
value={camera.deviceId}
devices={camera.devices}
onSelect={(deviceId) => void camera.selectCamera(deviceId)}
/>
);
}
function DebugInputModeButton({
selected,
label,
onClick,
children,
}: {
selected: boolean;
label: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
aria-pressed={selected}
title={label}
onClick={onClick}
className={[
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors",
selected
? "bg-surface-strong text-foreground"
: "text-muted-soft hover:bg-surface-strong hover:text-foreground",
].join(" ")}
>
{children}
</button>
); );
} }
@@ -2148,6 +2151,7 @@ function DebugVoicePanel({
assistantId, assistantId,
preview, preview,
camera, camera,
vision,
}: { }: {
view: DebugView; view: DebugView;
showTranscript: boolean; showTranscript: boolean;
@@ -2155,6 +2159,7 @@ function DebugVoicePanel({
assistantId: string | null; assistantId: string | null;
preview: VoicePreview; preview: VoicePreview;
camera: CameraPreview; camera: CameraPreview;
vision: boolean;
}) { }) {
const { const {
status, status,
@@ -2170,11 +2175,13 @@ function DebugVoicePanel({
} = preview; } = preview;
const recording = status === "connecting" || status === "connected"; const recording = status === "connecting" || status === "connected";
const [textDraft, setTextDraft] = useState(""); const [textDraft, setTextDraft] = useState("");
const [inputMode, setInputMode] = useState<DebugInputMode>("mic");
const inChatView = view === "chat" && (!SHOW_VOICE_VIZ || showTranscript); const inChatView = view === "chat" && (!SHOW_VOICE_VIZ || showTranscript);
const idleOrFailed = status === "idle" || status === "failed";
const showIdleHub = const showIdleHub =
inChatView &&
messages.length === 0 && messages.length === 0 &&
(status === "idle" || status === "failed"); idleOrFailed &&
(inChatView || (vision && view === "video"));
function handleSendText() { function handleSendText() {
if (sendText(textDraft)) { if (sendText(textDraft)) {
@@ -2186,7 +2193,14 @@ function DebugVoicePanel({
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
{/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */} {/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}
<audio ref={audioRef} autoPlay playsInline className="hidden" /> <audio ref={audioRef} autoPlay playsInline className="hidden" />
{view === "video" ? ( {view === "video" && showIdleHub ? (
<DebugIdleHub
status={status}
error={error}
assistantId={assistantId}
connect={connect}
/>
) : view === "video" ? (
<DebugVideoPanel camera={camera} /> <DebugVideoPanel camera={camera} />
) : !SHOW_VOICE_VIZ || showTranscript ? ( ) : !SHOW_VOICE_VIZ || showTranscript ? (
showIdleHub ? ( showIdleHub ? (
@@ -2200,7 +2214,7 @@ function DebugVoicePanel({
<DebugTranscriptPanel messages={messages} recording={recording} /> <DebugTranscriptPanel messages={messages} recording={recording} />
) )
) : ( ) : (
<div className="relative flex min-h-0 flex-1 flex-col items-center justify-center gap-3 overflow-y-auto px-6 py-3 text-center"> <div className="scrollbar-subtle relative flex min-h-0 flex-1 flex-col items-center justify-center gap-3 overflow-y-auto px-6 py-3 text-center">
<div <div
className="pointer-events-none absolute left-1/2 top-2 h-72 w-72 -translate-x-1/2 rounded-full opacity-50 blur-3xl" className="pointer-events-none absolute left-1/2 top-2 h-72 w-72 -translate-x-1/2 rounded-full opacity-50 blur-3xl"
style={{ style={{
@@ -2293,34 +2307,63 @@ function DebugVoicePanel({
)} )}
<div className="shrink-0 border-t border-hairline bg-card p-3"> <div className="shrink-0 border-t border-hairline bg-card p-3">
<div className="flex items-end gap-2"> <div className="flex items-center gap-2">
<Textarea <div
rows={1} className={[
value={textDraft} "flex h-10 min-w-0 items-center gap-1 rounded-[1.4rem] border border-hairline-strong bg-background px-2",
disabled={status !== "connected"} "flex-1",
onChange={(event) => setTextDraft(event.target.value)} ].join(" ")}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
handleSendText();
}
}}
placeholder={
status === "connected"
? "输入文字发送给助手,将打断当前播报…"
: "开始对话后可输入文字…"
}
className="max-h-24 min-h-10 flex-1 resize-none border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/>
<Button
size="icon"
className="shrink-0"
aria-label="发送调试消息"
disabled={status !== "connected" || !textDraft.trim()}
onClick={handleSendText}
> >
<Send size={16} /> <div className="flex shrink-0 items-center gap-0.5 rounded-full bg-canvas-soft p-0.5">
</Button> <DebugInputModeButton
selected={inputMode === "mic"}
label="选择麦克风设备"
onClick={() => setInputMode("mic")}
>
<Mic size={15} />
</DebugInputModeButton>
<DebugInputModeButton
selected={inputMode === "text"}
label="文字输入"
onClick={() => setInputMode("text")}
>
<MessageSquareText size={15} />
</DebugInputModeButton>
</div>
{inputMode === "mic" ? (
<MicrophoneDeviceField preview={preview} />
) : (
<Textarea
rows={1}
value={textDraft}
disabled={status !== "connected"}
onChange={(event) => setTextDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
handleSendText();
}
}}
placeholder={
status === "connected"
? "输入文字发送给助手,将打断当前播报…"
: "开始对话后可输入文字…"
}
className="h-8 min-h-8 flex-1 resize-none overflow-hidden border-transparent bg-transparent px-2 py-1 text-sm leading-6 text-foreground shadow-none outline-none placeholder:text-muted-soft focus-visible:ring-0 disabled:opacity-100"
/>
)}
</div>
{inputMode === "text" && (
<Button
size="icon"
className="h-10 w-10 shrink-0 rounded-full"
aria-label="发送调试消息"
disabled={status !== "connected" || !textDraft.trim()}
onClick={handleSendText}
>
<Send size={16} />
</Button>
)}
{!showIdleHub && ( {!showIdleHub && (
<Button <Button
size="sm" size="sm"
@@ -2333,7 +2376,7 @@ function DebugVoicePanel({
} }
}} }}
className={[ className={[
"shrink-0 gap-1.5 rounded-full", "h-10 shrink-0 gap-1.5 rounded-full px-4",
recording recording
? "bg-destructive text-white hover:bg-destructive/90" ? "bg-destructive text-white hover:bg-destructive/90"
: "", : "",
@@ -2356,7 +2399,7 @@ function DebugVoicePanel({
{/* 底部双轨波形:会话连通后默认展开,空闲时收起 */} {/* 底部双轨波形:会话连通后默认展开,空闲时收起 */}
<WaveformTimelinePanel <WaveformTimelinePanel
key={status === "connected" ? "active" : "idle"} key={status === "connected" ? "active" : "idle"}
defaultOpen={status === "connected"} defaultOpen={false}
userStream={localStream} userStream={localStream}
agentStream={remoteStream} agentStream={remoteStream}
active={status === "connected"} active={status === "connected"}
@@ -2365,10 +2408,9 @@ function DebugVoicePanel({
); );
} }
// 空闲态:图标 + 说明 + 开始按钮合一,替代「撑满高度的空 transcript + 底部细控制条」 // 空闲态只保留一个明确入口,避免中间区域显得拥挤。
function DebugIdleHub({ function DebugIdleHub({
status, status,
error,
assistantId, assistantId,
connect, connect,
}: { }: {
@@ -2377,42 +2419,23 @@ function DebugIdleHub({
assistantId: string | null; assistantId: string | null;
connect: () => Promise<void>; connect: () => Promise<void>;
}) { }) {
const hint =
status === "failed"
? error || "连接失败,请确认后端已启动且助手已保存后重试。"
: !assistantId
? "请先保存助手,再开始语音预览。"
: "测试语音识别、响应速度与助手的播报效果。";
return ( return (
<div className="flex min-h-0 flex-1 items-center justify-center px-6 py-8"> <div className="flex min-h-0 flex-1 items-center justify-center px-6 py-8">
<div className="flex max-w-xs flex-col items-center gap-3 text-center"> <Button
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-canvas-soft"> disabled={!assistantId}
<Mic size={22} className="text-muted-soft" /> onClick={() => void connect()}
</div> className="h-11 gap-2 rounded-full px-6 text-sm font-medium shadow-sm"
<div className="font-display display-sm text-foreground"> aria-label={status === "failed" ? "重新连接" : "开始语音测试"}
{status === "failed" ? "连接失败" : "开始一次语音对话"} >
</div> <Mic size={18} />
<p className="text-xs leading-5 text-muted-foreground">{hint}</p> {status === "failed" ? "重新连接" : "开始对话"}
<Button </Button>
disabled={!assistantId}
onClick={() => void connect()}
className="h-11 gap-2 rounded-full px-6 text-sm font-medium shadow-sm"
aria-label={status === "failed" ? "重新连接" : "开始语音测试"}
>
<Mic size={18} />
{status === "failed" ? "重新连接" : "开始对话"}
</Button>
<p className="text-[11px] leading-4 text-muted-soft">
</p>
</div>
</div> </div>
); );
} }
function DebugVideoPanel({ camera }: { camera: CameraPreview }) { function DebugVideoPanel({ camera }: { camera: CameraPreview }) {
const { stream, error, starting, active, start } = camera; const { stream, error, starting, active } = camera;
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => { useEffect(() => {
@@ -2445,28 +2468,14 @@ function DebugVideoPanel({ camera }: { camera: CameraPreview }) {
</Badge> </Badge>
) : ( ) : (
<div className="flex max-w-xs flex-col items-center gap-3 px-6 text-center"> <div className="flex max-w-xs flex-col items-center gap-2 px-6 text-center">
<Video size={28} className="text-muted-soft" /> <Video size={28} className="text-muted-soft" />
<div className="text-sm font-medium text-foreground"> <div className="text-sm font-medium text-foreground">
{starting ? "正在开启摄像头…" : "摄像头未开启"} {starting ? "正在开启摄像头…" : "摄像头不可用"}
</div> </div>
<p className="text-xs leading-5 text-muted-foreground"> {error && (
{error ?? "开启摄像头预览画面。后续可将视频流接入后端视觉理解管线。"} <p className="text-xs leading-5 text-muted-foreground">{error}</p>
</p> )}
<Button
size="sm"
disabled={starting}
onClick={() => void start()}
className="gap-1.5 rounded-full"
aria-label="开启摄像头"
>
{starting ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Video size={14} />
)}
</Button>
</div> </div>
)} )}
</div> </div>
@@ -2536,7 +2545,7 @@ function DebugTranscriptPanel({
return ( return (
<div <div
className={[ className={[
"flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-3", "scrollbar-subtle flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-3",
recording ? "items-center justify-center" : "pt-5", recording ? "items-center justify-center" : "pt-5",
].join(" ")} ].join(" ")}
> >
@@ -2558,7 +2567,7 @@ function DebugTranscriptPanel({
return ( return (
<div <div
ref={scrollRef} ref={scrollRef}
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-4" className="scrollbar-subtle flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-4"
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{messages.map((message) => { {messages.map((message) => {
@@ -2949,21 +2958,51 @@ function ResourceSelectField({
} }
function ToggleRow({ function ToggleRow({
icon,
title, title,
description, description,
hint,
checked, checked,
onChange, onChange,
}: { }: {
icon?: React.ReactNode;
title: string; title: string;
description: string; description?: string;
hint?: string;
checked: boolean; checked: boolean;
onChange: (checked: boolean) => void; onChange: (checked: boolean) => void;
}) { }) {
const hasIcon = Boolean(icon);
return ( return (
<div className="flex items-center justify-between rounded-xl border border-hairline bg-canvas-soft p-4"> <div
className={[
"flex items-center justify-between border border-hairline bg-canvas-soft",
hasIcon ? "rounded-2xl p-5" : "rounded-xl p-4",
].join(" ")}
>
<div> <div>
<div className="font-medium text-foreground">{title}</div> <div
<div className="mt-1 text-sm text-muted-foreground">{description}</div> className={[
"flex items-center font-medium text-foreground",
hasIcon ? "gap-3" : "gap-1.5",
].join(" ")}
>
{icon && (
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
{icon}
</span>
)}
<span className="flex items-center gap-1.5">
<span>{title}</span>
{hint && <HelpHint text={hint} />}
</span>
</div>
{description && (
<div className="mt-1 text-sm text-muted-foreground">
{description}
</div>
)}
</div> </div>
<Switch checked={checked} onCheckedChange={onChange} /> <Switch checked={checked} onCheckedChange={onChange} />
</div> </div>

View File

@@ -187,6 +187,8 @@ export function useVoicePreview(
releaseResources(); releaseResources();
setLocalStream(null); setLocalStream(null);
setRemoteStream(null); setRemoteStream(null);
setMessages([]);
messageSeqRef.current = 0;
setError(null); setError(null);
setMicWarning(null); setMicWarning(null);
setStatus("idle"); setStatus("idle");