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:
Nate Kelley 2025-09-30 22:31:00 -06:00 committed by GitHub
commit cd6a85b62c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 234 additions and 124 deletions

View File

@ -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]);
};

View File

@ -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>

View File

@ -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]);
};

View File

@ -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 }) => {

View File

@ -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',

View File

@ -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

View File

@ -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 = {

View File

@ -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}

View File

@ -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>
)}

View File

@ -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

View File

@ -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>}
</>
);

View File

@ -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;
}

View File

@ -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 = () => {

View File

@ -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)',
};
};