mirror of https://github.com/buster-so/buster.git
Merge pull request #1230 from buster-so/big-nate-bus-2016-update-for-shortcuts-and-suggestions
Big nate bus 2016 update for shortcuts and suggestions
This commit is contained in:
commit
cd6a85b62c
|
@ -1,29 +1,24 @@
|
|||
import type { ListShortcutsResponse } from '@buster/server-shared/shortcuts';
|
||||
import type { GetSuggestedPromptsResponse } from '@buster/server-shared/user';
|
||||
import omit from 'lodash/omit';
|
||||
import sampleSize from 'lodash/sampleSize';
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
useCreateShortcutsMentionsSuggestions,
|
||||
useShortcutsSuggestions,
|
||||
} from '@/components/features/input/Mentions/ShortcutsSuggestions/ShortcutsSuggestions';
|
||||
import CircleQuestion from '@/components/ui/icons/NucleoIconOutlined/circle-question';
|
||||
import FileSparkle from '@/components/ui/icons/NucleoIconOutlined/file-sparkle';
|
||||
import type {
|
||||
MentionArrayItem,
|
||||
MentionSuggestionExtension,
|
||||
} from '@/components/ui/inputs/MentionInput';
|
||||
import type {
|
||||
MentionInputSuggestionsDropdownItem,
|
||||
MentionInputSuggestionsProps,
|
||||
MentionInputSuggestionsRef,
|
||||
} from '@/components/ui/inputs/MentionInputSuggestions';
|
||||
import { MentionInputSuggestions } from '@/components/ui/inputs/MentionInputSuggestions';
|
||||
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
|
||||
import { useMount } from '@/hooks/useMount';
|
||||
import { ASSET_ICONS } from '../../icons/assetIcons';
|
||||
import { NewShortcutModal } from '../../modals/NewShortcutModal';
|
||||
import { BusterChatInputButtons, type BusterChatInputMode } from './BusterChatInputButtons';
|
||||
import { useUniqueSuggestions } from './useUniqueSuggestions';
|
||||
|
||||
export type BusterChatInputProps = {
|
||||
defaultValue: string;
|
||||
|
@ -131,7 +126,8 @@ export const BusterChatInputBase: React.FC<BusterChatInputProps> = React.memo(
|
|||
placeholder="Ask a question or type ‘/’ for shortcuts..."
|
||||
ref={mentionInputSuggestionsRef}
|
||||
inputContainerClassName="px-5 pt-4"
|
||||
inputClassName="text-lg"
|
||||
inputClassName="text-md"
|
||||
behavior="open-on-focus"
|
||||
>
|
||||
<BusterChatInputButtons
|
||||
onSubmit={onSubmitPreflight}
|
||||
|
@ -150,55 +146,3 @@ export const BusterChatInputBase: React.FC<BusterChatInputProps> = React.memo(
|
|||
);
|
||||
|
||||
BusterChatInputBase.displayName = 'BusterChatInputBase';
|
||||
|
||||
const iconRecord: Record<keyof GetSuggestedPromptsResponse['suggestedPrompts'], React.ReactNode> = {
|
||||
report: <FileSparkle />,
|
||||
dashboard: <ASSET_ICONS.dashboards />,
|
||||
visualization: <ASSET_ICONS.metrics />,
|
||||
help: <CircleQuestion />,
|
||||
};
|
||||
|
||||
const useUniqueSuggestions = (
|
||||
suggestedPrompts: GetSuggestedPromptsResponse['suggestedPrompts']
|
||||
): MentionInputSuggestionsProps['suggestionItems'] => {
|
||||
return useMemo(() => {
|
||||
const filteredSuggestedPrompts = omit(suggestedPrompts, ['help']);
|
||||
const allSuggestions: { type: keyof typeof suggestedPrompts; value: string }[] = Object.entries(
|
||||
filteredSuggestedPrompts
|
||||
).flatMap(([key, value]) => {
|
||||
return value.map((prompt) => {
|
||||
return {
|
||||
type: key as keyof typeof suggestedPrompts,
|
||||
value: prompt,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Ensure we have at least 4 suggestions
|
||||
if (allSuggestions.length < 4) {
|
||||
throw new Error('Not enough suggestions available - need at least 4');
|
||||
}
|
||||
|
||||
const fourUniqueSuggestions = sampleSize(allSuggestions, 4);
|
||||
|
||||
const items: MentionInputSuggestionsDropdownItem[] = fourUniqueSuggestions.map((suggestion) => {
|
||||
const icon = iconRecord[suggestion.type] || <ASSET_ICONS.metircsAdd />;
|
||||
return {
|
||||
type: 'item',
|
||||
value: suggestion.type + suggestion.value,
|
||||
label: suggestion.value,
|
||||
icon,
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'group',
|
||||
label: 'Shortcuts',
|
||||
suggestionItems: items,
|
||||
addValueToInput: true,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
] satisfies MentionInputSuggestionsProps['suggestionItems'];
|
||||
}, [suggestedPrompts]);
|
||||
};
|
||||
|
|
|
@ -67,7 +67,12 @@ export const BusterChatInputButtons = React.memo(
|
|||
|
||||
return (
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<AppSegmented value={mode} options={modesOptions} onChange={(v) => onModeChange(v.value)} />
|
||||
<AppSegmented
|
||||
size="medium"
|
||||
value={mode}
|
||||
options={modesOptions}
|
||||
onChange={(v) => onModeChange(v.value)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{browserSupportsSpeechRecognition && (
|
||||
|
@ -85,8 +90,8 @@ export const BusterChatInputButtons = React.memo(
|
|||
variant={'ghost'}
|
||||
prefix={<Microphone />}
|
||||
onClick={listening ? onStopListening : onStartListening}
|
||||
loading={false}
|
||||
disabled={disabled}
|
||||
size={'tall'}
|
||||
className={cn(
|
||||
'origin-center transform-gpu transition-all duration-300 ease-out will-change-transform text-text-secondary',
|
||||
!disabled && 'hover:scale-110 active:scale-95',
|
||||
|
@ -109,7 +114,8 @@ export const BusterChatInputButtons = React.memo(
|
|||
>
|
||||
<Button
|
||||
rounding={'large'}
|
||||
variant={'default'}
|
||||
variant={'black'}
|
||||
size={'tall'}
|
||||
prefix={<ArrowUp />}
|
||||
onClick={
|
||||
submitting
|
||||
|
@ -126,7 +132,7 @@ export const BusterChatInputButtons = React.memo(
|
|||
loading={submitting}
|
||||
disabled={disabled || disableSubmit}
|
||||
className={cn(
|
||||
'origin-center transform-gpu transition-all duration-300 ease-out will-change-transform bg-transparent!',
|
||||
'origin-center transform-gpu transition-all duration-300 ease-out will-change-transform',
|
||||
!disabled && 'hover:scale-110 active:scale-95'
|
||||
)}
|
||||
/>
|
||||
|
@ -193,7 +199,7 @@ const modesOptions: AppSegmentedProps<BusterChatInputMode>['options'] = [
|
|||
description="Decides how long to think"
|
||||
icon={<Sparkle2 />}
|
||||
iconText="Auto Mode"
|
||||
content={`Dynamically pick between “Research” and “Deep Research”`}
|
||||
content={`Dynamically picks between Research and Deep Research modes`}
|
||||
>
|
||||
<Sparkle2 />
|
||||
</ModePopoverContent>
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
import type { GetSuggestedPromptsResponse } from '@buster/server-shared/user';
|
||||
import omit from 'lodash/omit';
|
||||
import sampleSize from 'lodash/sampleSize';
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import CircleQuestion from '@/components/ui/icons/NucleoIconOutlined/circle-question';
|
||||
import FileSparkle from '@/components/ui/icons/NucleoIconOutlined/file-sparkle';
|
||||
import type {
|
||||
MentionInputSuggestionsDropdownItem,
|
||||
MentionInputSuggestionsProps,
|
||||
} from '@/components/ui/inputs/MentionInputSuggestions';
|
||||
import { ASSET_ICONS } from '../../icons/assetIcons';
|
||||
|
||||
const iconRecord: Record<keyof GetSuggestedPromptsResponse['suggestedPrompts'], React.ReactNode> = {
|
||||
report: <FileSparkle />,
|
||||
dashboard: <ASSET_ICONS.dashboards />,
|
||||
visualization: <ASSET_ICONS.metrics />,
|
||||
help: <CircleQuestion />,
|
||||
};
|
||||
|
||||
export const useUniqueSuggestions = (
|
||||
suggestedPrompts: GetSuggestedPromptsResponse['suggestedPrompts']
|
||||
): MentionInputSuggestionsProps['suggestionItems'] => {
|
||||
return useMemo(() => {
|
||||
const filteredSuggestedPrompts = omit(suggestedPrompts, ['help']);
|
||||
const allSuggestions: { type: keyof typeof suggestedPrompts; value: string }[] = Object.entries(
|
||||
filteredSuggestedPrompts
|
||||
).flatMap(([key, value]) => {
|
||||
return value.map((prompt) => {
|
||||
return {
|
||||
type: key as keyof typeof suggestedPrompts,
|
||||
value: prompt,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Ensure we have at least 4 suggestions
|
||||
if (allSuggestions.length < 4) {
|
||||
throw new Error('Not enough suggestions available - need at least 4');
|
||||
}
|
||||
|
||||
const fourUniqueSuggestions = sampleSize(allSuggestions, 4);
|
||||
|
||||
const items: MentionInputSuggestionsDropdownItem[] = fourUniqueSuggestions.map((suggestion) => {
|
||||
const icon = iconRecord[suggestion.type] || <ASSET_ICONS.metircsAdd />;
|
||||
return {
|
||||
type: 'item',
|
||||
value: suggestion.type + suggestion.value,
|
||||
label: suggestion.value,
|
||||
icon,
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'group',
|
||||
label: 'Suggestions',
|
||||
suggestionItems: items,
|
||||
addValueToInput: true,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
] satisfies MentionInputSuggestionsProps['suggestionItems'];
|
||||
}, [suggestedPrompts]);
|
||||
};
|
|
@ -4,7 +4,7 @@ import type { Editor } from '@tiptap/react';
|
|||
import { useMemo, useRef } from 'react';
|
||||
import { useDeleteShortcut, useGetShortcut } from '@/api/buster_rest/shortcuts/queryRequests';
|
||||
import { ErrorCard } from '@/components/ui/error/ErrorCard';
|
||||
import { Trash } from '@/components/ui/icons';
|
||||
import { Pencil, Trash } from '@/components/ui/icons';
|
||||
import PenWriting from '@/components/ui/icons/NucleoIconOutlined/pen-writing';
|
||||
import Plus from '@/components/ui/icons/NucleoIconOutlined/plus';
|
||||
import {
|
||||
|
@ -126,59 +126,76 @@ export const useCreateShortcutForMention = () => {
|
|||
return createShortcutForMention;
|
||||
};
|
||||
export const useShortcutsSuggestions = (
|
||||
shortcuts: ListShortcutsResponse['shortcuts'],
|
||||
_shortcuts: ListShortcutsResponse['shortcuts'],
|
||||
setOpenCreateShortcutModal: (open: boolean) => void,
|
||||
mentionInputSuggestionsRef: React.RefObject<MentionInputSuggestionsRef | null>
|
||||
): MentionInputSuggestionsProps['suggestionItems'] => {
|
||||
const createShortcutForMention = useCreateShortcutForMention();
|
||||
const navigate = useNavigate();
|
||||
return useMemo(() => {
|
||||
const shortcutsItems = shortcuts.map<MentionInputSuggestionsDropdownItem>((shortcut) => {
|
||||
return {
|
||||
const shortcutsItems: MentionInputSuggestionsProps['suggestionItems'] = [
|
||||
{
|
||||
type: 'item',
|
||||
value: shortcut.name,
|
||||
label: shortcut.name,
|
||||
popoverContent: <ShortcutSuggestionsPopoverContent shortcut={shortcut} />,
|
||||
icon: SHORTCUT_MENTION_TRIGGER,
|
||||
inputValue: `${SHORTCUT_MENTION_TRIGGER} ${shortcut.name}`,
|
||||
value: 'manageShortcuts',
|
||||
label: 'Manage shortcuts',
|
||||
keywords: ['/', 'manage', 'shortcuts'],
|
||||
icon: <PenWriting />,
|
||||
onClick: () => {
|
||||
const addMentionToInput = mentionInputSuggestionsRef.current?.addMentionToInput;
|
||||
if (!addMentionToInput) {
|
||||
console.warn('addMentionToInput is not defined', mentionInputSuggestionsRef.current);
|
||||
return;
|
||||
}
|
||||
const shortcutForMention = createShortcutForMention(shortcut);
|
||||
addMentionToInput?.({
|
||||
...shortcutForMention,
|
||||
trigger: SHORTCUT_MENTION_TRIGGER,
|
||||
navigate({
|
||||
to: '/app/home/shortcuts',
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
shortcutsItems.push({
|
||||
type: 'item',
|
||||
value: 'createShortcut',
|
||||
label: 'Create shortcut',
|
||||
keywords: ['create', 'shortcut'],
|
||||
icon: <Plus />,
|
||||
inputValue: `${SHORTCUT_MENTION_TRIGGER} Create shortcut`,
|
||||
onClick: () => {
|
||||
setOpenCreateShortcutModal(true);
|
||||
},
|
||||
closeOnSelect: false,
|
||||
addValueToInput: false,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'group',
|
||||
label: 'Shortcuts',
|
||||
suggestionItems: shortcutsItems,
|
||||
closeOnSelect: false,
|
||||
addValueToInput: false,
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
value: 'createShortcut',
|
||||
label: 'Create shortcut',
|
||||
keywords: ['/', 'create', 'shortcut'],
|
||||
icon: <Plus />,
|
||||
onClick: () => {
|
||||
setOpenCreateShortcutModal(true);
|
||||
},
|
||||
closeOnSelect: false,
|
||||
addValueToInput: false,
|
||||
closeOnSelect: true,
|
||||
},
|
||||
];
|
||||
}, [shortcuts, setOpenCreateShortcutModal, mentionInputSuggestionsRef]);
|
||||
// const shortcutsItems = shortcuts.map<MentionInputSuggestionsDropdownItem>((shortcut) => {
|
||||
// return {
|
||||
// type: 'item',
|
||||
// value: shortcut.name,
|
||||
// label: shortcut.name,
|
||||
// popoverContent: <ShortcutSuggestionsPopoverContent shortcut={shortcut} />,
|
||||
// icon: SHORTCUT_MENTION_TRIGGER,
|
||||
// inputValue: `${SHORTCUT_MENTION_TRIGGER} ${shortcut.name}`,
|
||||
// onClick: () => {
|
||||
// const addMentionToInput = mentionInputSuggestionsRef.current?.addMentionToInput;
|
||||
// if (!addMentionToInput) {
|
||||
// console.warn('addMentionToInput is not defined', mentionInputSuggestionsRef.current);
|
||||
// return;
|
||||
// }
|
||||
// const shortcutForMention = createShortcutForMention(shortcut);
|
||||
// addMentionToInput?.({
|
||||
// ...shortcutForMention,
|
||||
// trigger: SHORTCUT_MENTION_TRIGGER,
|
||||
// });
|
||||
// },
|
||||
// };
|
||||
// });
|
||||
|
||||
return shortcutsItems;
|
||||
|
||||
// return [
|
||||
// {
|
||||
// type: 'group',
|
||||
// label: 'Shortcuts',
|
||||
// suggestionItems: shortcutsItems,
|
||||
// addValueToInput: false,
|
||||
// closeOnSelect: true,
|
||||
// },
|
||||
// ];
|
||||
}, [setOpenCreateShortcutModal, mentionInputSuggestionsRef]);
|
||||
};
|
||||
|
||||
const ShortcutSuggestionsPopoverContent = ({ shortcut }: { shortcut: Shortcut }) => {
|
||||
|
|
|
@ -7,7 +7,8 @@ import { CircleSpinnerLoader } from '../loaders/CircleSpinnerLoader';
|
|||
export const buttonTypeClasses = {
|
||||
default:
|
||||
'bg-background border hover:bg-item-hover disabled:bg-disabled disabled:text-gray-light active:bg-item-active data-[selected=true]:bg-item-select',
|
||||
black: 'bg-black text-white hover:bg-foreground-hover disabled:bg-black/60',
|
||||
black:
|
||||
'bg-black text-white hover:bg-foreground-hover disabled:bg-background disabled:text-gray-light disabled:ring-[0.5px] ring-border ',
|
||||
outlined:
|
||||
'bg-transparent border border-border text-gray-dark hover:bg-item-hover disabled:bg-transparent disabled:text-gray-light active:bg-item-active data-[selected=true]:bg-item-select',
|
||||
primary:
|
||||
|
@ -111,7 +112,7 @@ export const buttonIconVariants = cva('', {
|
|||
{
|
||||
variant: 'black',
|
||||
disabled: true,
|
||||
className: 'text-white',
|
||||
className: 'text-gray-light',
|
||||
},
|
||||
{
|
||||
variant: 'outlined',
|
||||
|
|
|
@ -49,17 +49,22 @@ export const MentionInputSuggestions = forwardRef<
|
|||
//mentions
|
||||
onMentionItemClick,
|
||||
mentions,
|
||||
behavior = 'default',
|
||||
}: MentionInputSuggestionsProps,
|
||||
ref
|
||||
) => {
|
||||
const [hasClickedSelect, setHasClickedSelect] = useState(false);
|
||||
const [hasInteracted, setHasInteracted] = useState(behavior === 'default');
|
||||
const [value, setValue] = useState(valueProp ?? defaultValue);
|
||||
|
||||
const commandListNavigatedRef = useRef(false);
|
||||
const commandRef = useRef<HTMLDivElement>(null);
|
||||
const mentionsInputRef = useRef<MentionInputRef>(null);
|
||||
|
||||
const showSuggestionList = !hasClickedSelect && suggestionItems.length > 0;
|
||||
const showSuggestionList =
|
||||
behavior === 'default'
|
||||
? !hasClickedSelect && suggestionItems.length > 0
|
||||
: hasInteracted && suggestionItems.length > 0;
|
||||
|
||||
const onChangeInputValue: MentionInputProps['onChange'] = useCallback(
|
||||
(d) => {
|
||||
|
@ -68,8 +73,9 @@ export const MentionInputSuggestions = forwardRef<
|
|||
onChange?.(d);
|
||||
commandListNavigatedRef.current = false;
|
||||
setHasClickedSelect(false);
|
||||
setHasInteracted(true);
|
||||
},
|
||||
[onChange, setHasClickedSelect]
|
||||
[onChange, setHasClickedSelect, setHasInteracted]
|
||||
);
|
||||
|
||||
//Exported: this is used to change the value of the input from outside the component
|
||||
|
@ -176,13 +182,16 @@ export const MentionInputSuggestions = forwardRef<
|
|||
ref={commandRef}
|
||||
label={ariaLabel}
|
||||
className={cn(
|
||||
'relative border rounded overflow-hidden bg-background shadow',
|
||||
'relative border rounded-xl overflow-hidden bg-background shadow',
|
||||
// CSS-only solution: Hide separators that come after hidden elements
|
||||
'[&_[hidden]+[data-separator-after-hidden]]:hidden',
|
||||
className
|
||||
)}
|
||||
shouldFilter={shouldFilter}
|
||||
filter={filter || customFilter}
|
||||
onClick={() => {
|
||||
setHasInteracted(true);
|
||||
}}
|
||||
>
|
||||
<MentionInputSuggestionsContainer className={inputContainerClassName}>
|
||||
<MentionInputSuggestionsMentionsInput
|
||||
|
@ -200,7 +209,7 @@ export const MentionInputSuggestions = forwardRef<
|
|||
disabled={disabled}
|
||||
className={inputClassName}
|
||||
/>
|
||||
{children && <div className="mt-3">{children}</div>}
|
||||
{children && <div className="mt-4.5">{children}</div>}
|
||||
</MentionInputSuggestionsContainer>
|
||||
<SuggestionsSeperator />
|
||||
<MentionInputSuggestionsList
|
||||
|
|
|
@ -86,6 +86,7 @@ export type MentionInputSuggestionsProps<T = string> = {
|
|||
inputContainerClassName?: string;
|
||||
suggestionsContainerClassName?: string;
|
||||
inputClassName?: string;
|
||||
behavior?: 'default' | 'open-on-focus';
|
||||
} & Pick<React.ComponentProps<typeof Command>, 'filter' | 'shouldFilter'>;
|
||||
|
||||
export type MentionInputSuggestionsRef = {
|
||||
|
|
|
@ -27,7 +27,7 @@ export const MentionInputSuggestionsGroup = ({
|
|||
return (
|
||||
<Command.Group
|
||||
className={cn(
|
||||
'text-text-tertiary overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-2.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-base [&_[cmdk-group-heading]]:h-8',
|
||||
'text-text-tertiary overflow-hidden [&_[cmdk-group-heading]]:px-0 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-base [&_[cmdk-group-heading]]:h-8',
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
|
|
|
@ -34,7 +34,7 @@ export const MentionInputSuggestionsItem = ({
|
|||
<PopoverContentWrapper popoverContent={popoverContent}>
|
||||
<Command.Item
|
||||
className={cn(
|
||||
'data-[selected=true]:bg-item-hover data-[selected=true]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-base outline-none select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
'data-[selected=true]:bg-item-hover data-[selected=true]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-base outline-none select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
!disabled ? 'cursor-pointer' : 'cursor-not-allowed',
|
||||
'text-secondary group min-h-9',
|
||||
className
|
||||
|
@ -56,7 +56,7 @@ export const MentionInputSuggestionsItem = ({
|
|||
}}
|
||||
>
|
||||
{icon && (
|
||||
<span className="text-icon-color min-w-4 w-4 text-center group-hover:text-foreground">
|
||||
<span className="text-icon-color text-center group-hover:text-foreground text-icon-size-sm size-3">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
@ -15,7 +15,7 @@ export const MentionInputSuggestionsSeparator = ({
|
|||
// biome-ignore lint/a11y/useAriaPropsForRole: blitz bama blitz
|
||||
role="separator"
|
||||
className={cn(
|
||||
'bg-border -mx-1 h-px my-1.5',
|
||||
'bg-border my-1.5 h-[0.5px]',
|
||||
// Hide if first child
|
||||
'first:hidden',
|
||||
// Hide if last child
|
||||
|
|
|
@ -41,7 +41,7 @@ export interface AppSegmentedProps<
|
|||
value?: T;
|
||||
onChange?: (value: SegmentedItem<T, TRouter, TOptions, TFrom>) => void;
|
||||
className?: string;
|
||||
size?: 'default' | 'large';
|
||||
size?: 'default' | 'large' | 'medium';
|
||||
block?: boolean;
|
||||
type?: 'button' | 'track';
|
||||
disabled?: boolean;
|
||||
|
@ -77,7 +77,7 @@ const triggerVariants = cva(
|
|||
variants: {
|
||||
size: {
|
||||
default: 'flex-row min-w-6',
|
||||
medium: 'px-3 flex-row',
|
||||
medium: 'min-w-7 flex-row',
|
||||
large: 'px-3 flex-col',
|
||||
},
|
||||
block: {
|
||||
|
@ -284,7 +284,11 @@ function SegmentedTriggerComponent<
|
|||
|
||||
const linkContent = (
|
||||
<>
|
||||
{icon && <span className={cn('flex items-center text-sm')}>{icon}</span>}
|
||||
{icon && (
|
||||
<span className={cn('flex items-center text-sm', size === 'medium' && 'text-lg')}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
{label && <span className={cn('text-sm')}>{label}</span>}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
interface BrowserInfo {
|
||||
isEdge: boolean;
|
||||
isChrome: boolean;
|
||||
isSafari: boolean;
|
||||
isFirefox: boolean;
|
||||
browserName: string;
|
||||
}
|
||||
|
||||
export function useBrowserDetection(): BrowserInfo {
|
||||
const browserInfo = useMemo(() => {
|
||||
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
||||
return {
|
||||
isEdge: false,
|
||||
isChrome: false,
|
||||
isSafari: false,
|
||||
isFirefox: false,
|
||||
browserName: 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const isEdge = userAgent.includes('edg/');
|
||||
const isChrome = userAgent.includes('chrome') && !isEdge;
|
||||
const isSafari = userAgent.includes('safari') && !isChrome && !isEdge;
|
||||
const isFirefox = userAgent.includes('firefox');
|
||||
|
||||
let browserName = 'unknown';
|
||||
if (isEdge) browserName = 'edge';
|
||||
else if (isChrome) browserName = 'chrome';
|
||||
else if (isSafari) browserName = 'safari';
|
||||
else if (isFirefox) browserName = 'firefox';
|
||||
|
||||
return {
|
||||
isEdge,
|
||||
isChrome,
|
||||
isSafari,
|
||||
isFirefox,
|
||||
browserName,
|
||||
};
|
||||
}, []);
|
||||
|
||||
return browserInfo;
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { openErrorNotification } from '@/context/BusterNotifications';
|
||||
import { useBrowserDetection } from './useBrowserDetection';
|
||||
|
||||
// Type definitions for Web Speech API
|
||||
interface SpeechRecognitionErrorEvent extends Event {
|
||||
|
@ -54,10 +55,14 @@ export function useSpeechRecognition(): UseSpeechRecognitionReturn {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasPermission, setHasPermission] = useState(false);
|
||||
const finalTranscriptRef = useRef('');
|
||||
const { isEdge, isFirefox } = useBrowserDetection();
|
||||
|
||||
// Check browser support
|
||||
// Check browser support - disable for Edge due to language support issues
|
||||
const browserSupportsSpeechRecognition =
|
||||
typeof window !== 'undefined' && (window.SpeechRecognition || window.webkitSpeechRecognition);
|
||||
!isEdge &&
|
||||
!isFirefox &&
|
||||
typeof window !== 'undefined' &&
|
||||
(window.SpeechRecognition || window.webkitSpeechRecognition);
|
||||
|
||||
// Check microphone permission
|
||||
useEffect(() => {
|
||||
|
@ -91,7 +96,8 @@ export function useSpeechRecognition(): UseSpeechRecognitionReturn {
|
|||
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
recognition.lang = 'en-US';
|
||||
// Don't set lang - let it use browser default
|
||||
// Edge can be particular about language codes
|
||||
|
||||
recognition.onstart = () => {
|
||||
setListening(true);
|
||||
|
@ -131,7 +137,10 @@ export function useSpeechRecognition(): UseSpeechRecognitionReturn {
|
|||
openErrorNotification({ message });
|
||||
setError(message);
|
||||
|
||||
onStopListening();
|
||||
// Stop recognition and listening state
|
||||
recognition.stop();
|
||||
recognition.abort();
|
||||
setListening(false);
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
|
|
|
@ -48,6 +48,7 @@ export const createCspHeader = (isEmbed = false): string => {
|
|||
(() => {
|
||||
const connectSources = [
|
||||
"'self'",
|
||||
'blob:',
|
||||
'data:', // Allow data URLs for PDF exports and other data URI downloads
|
||||
localDomains,
|
||||
supabaseOrigin,
|
||||
|
@ -63,6 +64,15 @@ export const createCspHeader = (isEmbed = false): string => {
|
|||
'https://eu-assets.i.posthog.com',
|
||||
'https://*.cloudflareinsights.com',
|
||||
'https://*.slack.com',
|
||||
// Speech recognition API and Google services
|
||||
'https://*.google.com',
|
||||
'https://*.googleapis.com',
|
||||
'https://apis.google.com',
|
||||
'https://ssl.gstatic.com',
|
||||
'https://www.google.com',
|
||||
'https://www.googletagmanager.com',
|
||||
'https://www.gstatic.com',
|
||||
'https://www.google-analytics.com',
|
||||
// Social media and video platform APIs for embeds
|
||||
'https://*.twitter.com',
|
||||
'https://twitter.com',
|
||||
|
@ -115,6 +125,6 @@ export const createSecurityHeaders = (isEmbed = false) => {
|
|||
'X-Content-Type-Options': 'nosniff',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
'Permissions-Policy': 'microphone=()',
|
||||
'Permissions-Policy': 'microphone=(self)',
|
||||
};
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue