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
26 changes: 22 additions & 4 deletions src/components/voice/VoiceMessagesDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import React, { useEffect, useRef, useState } from "react";
import { SendHorizontal } from "lucide-react";
import { SendHorizontal, Volume2, VolumeOff } from "lucide-react";
import { useVoiceStore, type AgentMessage } from "@/stores/useVoiceStore";
import {
Sheet,
Expand Down Expand Up @@ -50,6 +50,8 @@
const transcription = useVoiceStore((s) => s.transcription);
const isConnected = useVoiceStore((s) => s.isConnected);
const sendMessage = useVoiceStore((s) => s.sendMessage);
const ttsState = useVoiceStore((s) => s.ttsState);
const toggleTts = useVoiceStore((s) => s.toggleTts);
const bottomRef = useRef<HTMLDivElement>(null);
const [inputValue, setInputValue] = useState("");

Expand Down Expand Up @@ -77,9 +79,25 @@
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-sm flex flex-col h-full">
<SheetHeader>
<SheetTitle>Voice Agent</SheetTitle>
<SheetDescription>Chat with Jamie</SheetDescription>
<SheetHeader className="flex flex-row items-center justify-between">
<div>
<SheetTitle>Voice Agent</SheetTitle>
<SheetDescription>Chat with Jamie</SheetDescription>
</div>
{ttsState !== null && (
<Button
size="icon"
variant="ghost"
onClick={toggleTts}
aria-label={ttsState === "on" ? "Mute agent voice" : "Unmute agent voice"}
>
{ttsState === "on" ? (
<Volume2 className="h-4 w-4" />
) : (
<VolumeOff className="h-4 w-4" />

Check failure on line 97 in src/components/voice/VoiceMessagesDrawer.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

src/__tests__/unit/components/VoiceMessagesDrawer.test.tsx > VoiceMessagesDrawer — MessageBubble rendering > agent messages are rendered left-aligned with bg-muted

Error: [vitest] No "VolumeOff" export is defined on the "lucide-react" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("lucide-react"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ VoiceMessagesDrawer src/components/voice/VoiceMessagesDrawer.tsx:97:18 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom-client.development.js:17641:22 ❯ workLoopSync node_modules/react-dom/cjs/react-dom-client.development.js:17469:41

Check failure on line 97 in src/components/voice/VoiceMessagesDrawer.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

src/__tests__/unit/components/VoiceMessagesDrawer.test.tsx > VoiceMessagesDrawer — text input > pressing Shift+Enter does NOT submit

Error: [vitest] No "VolumeOff" export is defined on the "lucide-react" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("lucide-react"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ VoiceMessagesDrawer src/components/voice/VoiceMessagesDrawer.tsx:97:18 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom-client.development.js:17641:22 ❯ workLoopSync node_modules/react-dom/cjs/react-dom-client.development.js:17469:41

Check failure on line 97 in src/components/voice/VoiceMessagesDrawer.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

src/__tests__/unit/components/VoiceMessagesDrawer.test.tsx > VoiceMessagesDrawer — text input > pressing Enter calls sendMessage and clears input

Error: [vitest] No "VolumeOff" export is defined on the "lucide-react" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("lucide-react"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ VoiceMessagesDrawer src/components/voice/VoiceMessagesDrawer.tsx:97:18 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom-client.development.js:17641:22 ❯ workLoopSync node_modules/react-dom/cjs/react-dom-client.development.js:17469:41

Check failure on line 97 in src/components/voice/VoiceMessagesDrawer.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

src/__tests__/unit/components/VoiceMessagesDrawer.test.tsx > VoiceMessagesDrawer — text input > clicking Send calls sendMessage and clears input

Error: [vitest] No "VolumeOff" export is defined on the "lucide-react" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("lucide-react"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ VoiceMessagesDrawer src/components/voice/VoiceMessagesDrawer.tsx:97:18 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom-client.development.js:17641:22 ❯ workLoopSync node_modules/react-dom/cjs/react-dom-client.development.js:17469:41

Check failure on line 97 in src/components/voice/VoiceMessagesDrawer.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

src/__tests__/unit/components/VoiceMessagesDrawer.test.tsx > VoiceMessagesDrawer — text input > handleSend does not call sendMessage on whitespace-only input

Error: [vitest] No "VolumeOff" export is defined on the "lucide-react" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("lucide-react"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ VoiceMessagesDrawer src/components/voice/VoiceMessagesDrawer.tsx:97:18 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom-client.development.js:17641:22 ❯ workLoopSync node_modules/react-dom/cjs/react-dom-client.development.js:17469:41

Check failure on line 97 in src/components/voice/VoiceMessagesDrawer.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

src/__tests__/unit/components/VoiceMessagesDrawer.test.tsx > VoiceMessagesDrawer — text input > handleSend does not call sendMessage on empty input

Error: [vitest] No "VolumeOff" export is defined on the "lucide-react" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("lucide-react"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ VoiceMessagesDrawer src/components/voice/VoiceMessagesDrawer.tsx:97:18 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom-client.development.js:17641:22 ❯ workLoopSync node_modules/react-dom/cjs/react-dom-client.development.js:17469:41

Check failure on line 97 in src/components/voice/VoiceMessagesDrawer.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

src/__tests__/unit/components/VoiceMessagesDrawer.test.tsx > VoiceMessagesDrawer — text input > input and button are enabled when connected

Error: [vitest] No "VolumeOff" export is defined on the "lucide-react" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("lucide-react"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ VoiceMessagesDrawer src/components/voice/VoiceMessagesDrawer.tsx:97:18 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom-client.development.js:17641:22 ❯ workLoopSync node_modules/react-dom/cjs/react-dom-client.development.js:17469:41

Check failure on line 97 in src/components/voice/VoiceMessagesDrawer.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

src/__tests__/unit/components/VoiceMessagesDrawer.test.tsx > VoiceMessagesDrawer — text input > input and button are disabled when not connected

Error: [vitest] No "VolumeOff" export is defined on the "lucide-react" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("lucide-react"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ VoiceMessagesDrawer src/components/voice/VoiceMessagesDrawer.tsx:97:18 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom-client.development.js:17641:22 ❯ workLoopSync node_modules/react-dom/cjs/react-dom-client.development.js:17469:41

Check failure on line 97 in src/components/voice/VoiceMessagesDrawer.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

src/__tests__/unit/components/VoiceMessagesDrawer.test.tsx > VoiceMessagesDrawer — text input > SheetDescription shows 'Chat with Jamie'

Error: [vitest] No "VolumeOff" export is defined on the "lucide-react" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("lucide-react"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ VoiceMessagesDrawer src/components/voice/VoiceMessagesDrawer.tsx:97:18 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom-client.development.js:17641:22 ❯ workLoopSync node_modules/react-dom/cjs/react-dom-client.development.js:17469:41

Check failure on line 97 in src/components/voice/VoiceMessagesDrawer.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

src/__tests__/unit/components/VoiceMessagesDrawer.test.tsx > VoiceMessagesDrawer — text input > renders the chat input and send button when open

Error: [vitest] No "VolumeOff" export is defined on the "lucide-react" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("lucide-react"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ VoiceMessagesDrawer src/components/voice/VoiceMessagesDrawer.tsx:97:18 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom-client.development.js:17641:22 ❯ workLoopSync node_modules/react-dom/cjs/react-dom-client.development.js:17469:41
)}
</Button>
)}
</SheetHeader>

<div className="flex-1 overflow-y-auto px-4 pb-4 space-y-3">
Expand Down
35 changes: 34 additions & 1 deletion src/stores/useVoiceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@ interface VoiceState {
messages: AgentMessage[];
transcription: Transcription | null;

// TTS (voice mode) – mirrors the agent's `tts` attribute
ttsState: string | null;

// Actions
connect: (slug: string) => Promise<void>;
disconnect: () => void;
toggleMic: () => Promise<void>;
toggleTts: () => void;
sendMessage: (text: string) => void;
clearError: () => void;
}
Expand All @@ -49,6 +53,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
error: null,
messages: [],
transcription: null,
ttsState: null,

connect: async (slug: string) => {
if (get().isConnected || get().isConnecting) return;
Expand Down Expand Up @@ -104,8 +109,23 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
},
);

// Track the agent's TTS attribute (voice mode on/off)
const updateTts = () => {
for (const [, p] of room.remoteParticipants) {
if (p.attributes?.tts !== undefined) {
set({ ttsState: p.attributes.tts });
return;
}
}
set({ ttsState: null });
};
room.on(RoomEvent.Connected, updateTts);
room.on(RoomEvent.ParticipantConnected, updateTts);
room.on(RoomEvent.ParticipantDisconnected, updateTts);
room.on(RoomEvent.ParticipantAttributesChanged, updateTts);

room.on(RoomEvent.Disconnected, () => {
set({ isConnected: false, isConnecting: false, isMicEnabled: false, room: null });
set({ isConnected: false, isConnecting: false, isMicEnabled: false, ttsState: null, room: null });
});

await room.connect(LIVEKIT_URL, token);
Expand All @@ -132,6 +152,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
isMicEnabled: false,
messages: [],
transcription: null,
ttsState: null,
});
},

Expand All @@ -156,5 +177,17 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
set((s) => ({ messages: [...s.messages, msg] }));
},

toggleTts: () => {
const { room } = get();
if (!room) return;
const payload = new TextEncoder().encode(
JSON.stringify({ type: "tts-toggle" }),
);
room.localParticipant.publishData(payload, {
reliable: true,
topic: "lk-chat-topic",
});
},

clearError: () => set({ error: null }),
}));
Loading