Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
'use client';

import { type HTMLAttributes, useState } from 'react';
import { useEffect, useRef, type HTMLAttributes, useState } from 'react';
import { Track } from 'livekit-client';
import { motion } from 'motion/react';
import { useChat } from '@livekit/components-react';
import { MessageSquareTextIcon } from 'lucide-react';
import { Loader, MessageSquareTextIcon, SendHorizontal } from 'lucide-react';
import { Toggle } from '@/components/ui/toggle';
import { Button } from '@/components/ui/button';
import {
AgentTrackToggle,
agentTrackToggleVariants,
} from '@/components/agents-ui/agent-track-toggle';
import { AgentTrackControl } from '@/components/agents-ui/agent-track-control';
import { Toggle } from '@/components/ui/toggle';
import { AgentDisconnectButton } from '@/components/agents-ui/agent-disconnect-button';
import {
useInputControls,
usePublishPermissions,
type UseInputControlsProps,
} from '@/hooks/agents-ui/use-agent-control-bar';
import { cn } from '@/lib/utils';
import { AgentChatInput } from './agent-chat-input';
import { UseInputControlsProps, useInputControls } from './hooks/use-input-controls';
import { usePublishPermissions } from './hooks/use-publish-permissions';
import { AgentDisconnectButton } from '../agent-disconnect-button';

const TOGGLE_VARIANT_1 = [
'[&_[data-state=off]]:bg-accent [&_[data-state=off]]:hover:bg-foreground/10',
Expand Down Expand Up @@ -58,6 +61,71 @@ const MOTION_PROPS = {
},
};

interface AgentChatInputProps {
chatOpen: boolean;
onSend?: (message: string) => void;
className?: string;
}

export function AgentChatInput({
chatOpen,
onSend = async () => {},
className,
}: AgentChatInputProps) {
const inputRef = useRef<HTMLTextAreaElement>(null);
const [isSending, setIsSending] = useState(false);
const [message, setMessage] = useState<string>('');

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();

try {
setIsSending(true);
await onSend(message);
setMessage('');
} catch (error) {
console.error(error);
} finally {
setIsSending(false);
}
};

const isDisabled = isSending || message.trim().length === 0;

useEffect(() => {
if (chatOpen) return;
// when not disabled refocus on input
inputRef.current?.focus();
}, [chatOpen]);

return (
<form
onSubmit={handleSubmit}
className={cn('mb-3 flex grow items-end gap-2 rounded-md pl-1 text-sm', className)}
>
<textarea
autoFocus
ref={inputRef}
value={message}
disabled={!chatOpen}
placeholder="Type something..."
onChange={(e) => setMessage(e.target.value)}
className="field-sizing-content max-h-16 min-h-8 flex-1 py-2 [scrollbar-width:thin] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
<Button
size="icon"
type="submit"
disabled={isDisabled}
variant={isDisabled ? 'secondary' : 'default'}
title={isSending ? 'Sending...' : 'Send'}
className="self-end disabled:cursor-not-allowed"
>
{isSending ? <Loader className="animate-spin" /> : <SendHorizontal />}
</Button>
</form>
);
}

export interface ControlBarControls {
leave?: boolean;
camera?: boolean;
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
useMediaDeviceSelect,
} from '@livekit/components-react';

import { AudioVisualizerBar } from '@/components/agents-ui/audio-visualizer-bar/audio-visualizer-bar';
import { AudioVisualizerBar } from '@/components/agents-ui/audio-visualizer-bar';
import { AgentTrackToggle } from '@/components/agents-ui/agent-track-toggle';
import {
Select,
Expand Down
2 changes: 0 additions & 2 deletions packages/shadcn/components/agents-ui/agent-track-toggle.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
'use client';

import * as React from 'react';
import { cva } from 'class-variance-authority';
import { Track } from 'livekit-client';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,46 @@
import React, { type ReactNode, useMemo } from 'react';
'use client';

import React, {
type ReactNode,
type CSSProperties,
useMemo,
Children,
cloneElement,
isValidElement,
} from 'react';
import { type VariantProps, cva } from 'class-variance-authority';
import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client';
import {
type AgentState,
type TrackReferenceOrPlaceholder,
useMultibandTrackVolume,
} from '@livekit/components-react';
import { useBarAnimator } from '@/hooks/agents-ui/use-audio-visualizer-bar';
import { cn } from '@/lib/utils';
import { cloneSingleChild } from '@/lib/clone-single-child';
import { useBarAnimator } from './hooks/useBarAnimator';

export function cloneSingleChild(
children: ReactNode | ReactNode[],
props?: Record<string, unknown>,
key?: unknown,
) {
return Children.map(children, (child) => {
// Checking isValidElement is the safe way and avoids a typescript error too.
if (isValidElement(child) && Children.only(children)) {
const childProps = child.props as Record<string, unknown>;
if (childProps.className) {
// make sure we retain classnames of both passed props and child
props ??= {};
props.className = cn(childProps.className as string, props.className as string);
props.style = {
...(childProps.style as CSSProperties),
...(props.style as CSSProperties),
};
}
return cloneElement(child, { ...props, key: key ? String(key) : undefined });
}
return child;
});
}

export const AudioVisualizerBarVariants = cva(
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,52 @@ import { useCallback, useMemo } from 'react';
import { Track } from 'livekit-client';
import {
type TrackReferenceOrPlaceholder,
useTrackToggle,
useLocalParticipant,
usePersistentUserChoices,
useTrackToggle,
useLocalParticipantPermissions,
} from '@livekit/components-react';

const trackSourceToProtocol = (source: Track.Source) => {
// NOTE: this mapping avoids importing the protocol package as that leads to a significant bundle size increase
switch (source) {
case Track.Source.Camera:
return 1;
case Track.Source.Microphone:
return 2;
case Track.Source.ScreenShare:
return 3;
default:
return 0;
}
};

export interface PublishPermissions {
camera: boolean;
microphone: boolean;
screenShare: boolean;
data: boolean;
}

export function usePublishPermissions(): PublishPermissions {
const localPermissions = useLocalParticipantPermissions();

const canPublishSource = (source: Track.Source) => {
return (
!!localPermissions?.canPublish &&
(localPermissions.canPublishSources.length === 0 ||
localPermissions.canPublishSources.includes(trackSourceToProtocol(source)))
);
};

return {
camera: canPublishSource(Track.Source.Camera),
microphone: canPublishSource(Track.Source.Microphone),
screenShare: canPublishSource(Track.Source.ScreenShare),
data: localPermissions?.canPublishData ?? false,
};
}

export interface UseInputControlsProps {
saveUserChoices?: boolean;
onDisconnect?: () => void;
Expand Down Expand Up @@ -64,14 +105,14 @@ export function useInputControls({
(deviceId: string) => {
saveAudioInputDeviceId(deviceId ?? 'default');
},
[saveAudioInputDeviceId]
[saveAudioInputDeviceId],
);

const handleVideoDeviceChange = useCallback(
(deviceId: string) => {
saveVideoInputDeviceId(deviceId ?? 'default');
},
[saveVideoInputDeviceId]
[saveVideoInputDeviceId],
);

const handleToggleCamera = useCallback(
Expand All @@ -83,7 +124,7 @@ export function useInputControls({
// persist video input enabled preference
saveVideoInputEnabled(!cameraToggle.enabled);
},
[cameraToggle, screenShareToggle, saveVideoInputEnabled]
[cameraToggle, screenShareToggle, saveVideoInputEnabled],
);

const handleToggleMicrophone = useCallback(
Expand All @@ -92,7 +133,7 @@ export function useInputControls({
// persist audio input enabled preference
saveAudioInputEnabled(!microphoneToggle.enabled);
},
[microphoneToggle, saveAudioInputEnabled]
[microphoneToggle, saveAudioInputEnabled],
);

const handleToggleScreenShare = useCallback(
Expand All @@ -102,16 +143,16 @@ export function useInputControls({
}
await screenShareToggle.toggle(enabled);
},
[cameraToggle, screenShareToggle]
[cameraToggle, screenShareToggle],
);
const handleMicrophoneDeviceSelectError = useCallback(
(error: Error) => onDeviceError?.({ source: Track.Source.Microphone, error }),
[onDeviceError]
[onDeviceError],
);

const handleCameraDeviceSelectError = useCallback(
(error: Error) => onDeviceError?.({ source: Track.Source.Camera, error }),
[onDeviceError]
[onDeviceError],
);

return {
Expand Down
4 changes: 2 additions & 2 deletions packages/shadcn/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export * from './components/agents-ui/audio-visualizer-bar/audio-visualizer-bar';
export * from './components/agents-ui/audio-visualizer-bar';
export * from './components/agents-ui/agent-track-toggle';
export * from './components/agents-ui/agent-track-control';
export * from './components/agents-ui/agent-disconnect-button';
export * from './components/agents-ui/agent-control-bar/agent-control-bar';
export * from './components/agents-ui/agent-control-bar';
Loading