Merge remote-tracking branch 'origin/staging' into move-search-to-turbo-puffer

This commit is contained in:
dal 2025-09-09 10:04:50 -06:00
commit c354a08348
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
22 changed files with 1055 additions and 34 deletions

View File

@ -71,7 +71,8 @@ jobs:
run: |
echo "📦 Building standalone CLI binary for ${{ matrix.target }}..."
# Note: Bun compiles for the host platform, cross-compilation happens via matrix strategy
bun build src/index.tsx --compile --outfile dist/buster-cli
# Using --minify for production builds to reduce binary size
bun build src/index.tsx --compile --minify --outfile dist/buster-cli
# Make binary executable on Unix systems
if [[ "${{ runner.os }}" != "Windows" ]]; then

View File

@ -1,5 +1,6 @@
import { Box, Text } from 'ink';
import BigText from 'ink-big-text';
import React from 'react';
import { SimpleBigText } from './simple-big-text.js';
interface BannerProps {
showSubtitle?: boolean;
@ -8,17 +9,16 @@ interface BannerProps {
/**
* Shared Buster banner component for consistent branding across CLI
* Uses SimpleBigText to avoid font loading issues in standalone binaries
*/
export function BusterBanner({ showSubtitle = true, inline = false }: BannerProps = {}) {
const content = (
<>
<Box>
<Text color="#7C3AED">
<BigText text="BUSTER" font="block" />
</Text>
<SimpleBigText text="BUSTER" color="#7C3AED" />
</Box>
{showSubtitle && (
<Box>
<Box marginTop={1}>
<Text bold>Welcome to Buster</Text>
</Box>
)}

View File

@ -0,0 +1,41 @@
import { Text } from 'ink';
import React from 'react';
interface SimpleBigTextProps {
text: string;
color?: string;
}
/**
* Simple big text renderer that doesn't require external font files
* Uses inline ASCII art for each letter
*/
export function SimpleBigText({ text, color = 'white' }: SimpleBigTextProps) {
const letters: Record<string, string[]> = {
B: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██████╔╝', '╚═════╝ '],
U: ['██╗ ██╗', '██║ ██║', '██║ ██║', '██║ ██║', '╚██████╔╝', ' ╚═════╝ '],
S: ['███████╗', '██╔════╝', '███████╗', '╚════██║', '███████║', '╚══════╝'],
T: ['████████╗', '╚══██╔══╝', ' ██║ ', ' ██║ ', ' ██║ ', ' ╚═╝ '],
E: ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '███████╗', '╚══════╝'],
R: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██║ ██║', '╚═╝ ╚═╝'],
};
// Convert text to uppercase and get the ASCII art for each letter
const upperText = text.toUpperCase();
const lines: string[] = ['', '', '', '', '', ''];
for (const char of upperText) {
const letter = letters[char];
if (letter) {
for (let i = 0; i < 6; i++) {
lines[i] += letter[i] + ' ';
}
} else if (char === ' ') {
for (let i = 0; i < 6; i++) {
lines[i] += ' ';
}
}
}
return <Text color={color}>{lines.join('\n')}</Text>;
}

View File

@ -10,7 +10,7 @@ import { InitCommand } from './commands/init.js';
program
.name('buster')
.description('Buster CLI - AI-powered data analytics platform')
.version('0.1.0');
.version('0.3.0');
// Auth command - authentication management
program

View File

@ -367,6 +367,309 @@ describe('messagePostProcessingTask', () => {
});
});
describe('Slack notification skip logic', () => {
it('should skip Slack notification when no issues found AND no major assumptions', async () => {
const messageId = '123e4567-e89b-12d3-a456-426614174000';
const workflowOutput = {
flagChatResult: {
type: 'noIssuesFound' as const,
message: 'No issues found',
},
assumptionsResult: {
toolCalled: 'noAssumptions',
assumptions: undefined,
},
formattedMessage: undefined,
};
vi.mocked(helpers.fetchMessageWithContext).mockResolvedValue({
id: messageId,
chatId: 'chat-123',
createdBy: 'user-123',
createdAt: new Date(),
rawLlmMessages: [] as any,
userName: 'John Doe',
organizationId: 'org-123',
});
vi.mocked(helpers.fetchPreviousPostProcessingMessages).mockResolvedValue([]);
vi.mocked(helpers.fetchUserDatasets).mockResolvedValue({
datasets: [],
total: 0,
page: 0,
pageSize: 1000,
});
vi.mocked(helpers.getExistingSlackMessageForChat).mockResolvedValue({ exists: false });
vi.mocked(helpers.buildWorkflowInput).mockReturnValue({
conversationHistory: undefined,
userName: 'John Doe',
isFollowUp: false,
isSlackFollowUp: false,
datasets: '',
});
vi.mocked(postProcessingWorkflow).mockResolvedValue(workflowOutput);
await runTask({ messageId });
// Verify Slack notification was NOT sent
expect(helpers.sendSlackNotification).not.toHaveBeenCalled();
expect(helpers.sendSlackReplyNotification).not.toHaveBeenCalled();
});
it('should skip Slack notification with only minor assumptions', async () => {
const messageId = '123e4567-e89b-12d3-a456-426614174000';
const workflowOutput = {
flagChatResult: {
type: 'noIssuesFound' as const,
message: 'No issues found',
},
assumptionsResult: {
toolCalled: 'listAssumptions',
assumptions: [
{
descriptiveTitle: 'Minor assumption',
classification: 'fieldMapping',
explanation: 'Test explanation',
label: 'minor' as const,
},
],
},
formattedMessage: undefined,
};
vi.mocked(helpers.fetchMessageWithContext).mockResolvedValue({
id: messageId,
chatId: 'chat-123',
createdBy: 'user-123',
createdAt: new Date(),
rawLlmMessages: [] as any,
userName: 'John Doe',
organizationId: 'org-123',
});
vi.mocked(helpers.fetchPreviousPostProcessingMessages).mockResolvedValue([]);
vi.mocked(helpers.fetchUserDatasets).mockResolvedValue({
datasets: [],
total: 0,
page: 0,
pageSize: 1000,
});
vi.mocked(helpers.getExistingSlackMessageForChat).mockResolvedValue({ exists: false });
vi.mocked(helpers.buildWorkflowInput).mockReturnValue({
conversationHistory: undefined,
userName: 'John Doe',
isFollowUp: false,
isSlackFollowUp: false,
datasets: '',
});
vi.mocked(postProcessingWorkflow).mockResolvedValue(workflowOutput);
await runTask({ messageId });
// Verify Slack notification was NOT sent (only minor assumptions)
expect(helpers.sendSlackNotification).not.toHaveBeenCalled();
expect(helpers.sendSlackReplyNotification).not.toHaveBeenCalled();
});
it('should send Slack notification when flagChat even without major assumptions', async () => {
const messageId = '123e4567-e89b-12d3-a456-426614174000';
const workflowOutput = {
flagChatResult: {
type: 'flagChat' as const,
summaryMessage: 'Issues found',
summaryTitle: 'Issue Title',
},
assumptionsResult: {
toolCalled: 'noAssumptions',
assumptions: undefined,
},
formattedMessage: 'Formatted message',
};
vi.mocked(helpers.fetchMessageWithContext).mockResolvedValue({
id: messageId,
chatId: 'chat-123',
createdBy: 'user-123',
createdAt: new Date(),
rawLlmMessages: [] as any,
userName: 'John Doe',
organizationId: 'org-123',
});
vi.mocked(helpers.fetchPreviousPostProcessingMessages).mockResolvedValue([]);
vi.mocked(helpers.fetchUserDatasets).mockResolvedValue({
datasets: [],
total: 0,
page: 0,
pageSize: 1000,
});
vi.mocked(helpers.getExistingSlackMessageForChat).mockResolvedValue({ exists: false });
vi.mocked(helpers.sendSlackNotification).mockResolvedValue({
sent: true,
messageTs: 'msg-ts-123',
integrationId: 'int-123',
channelId: 'C123456',
});
vi.mocked(helpers.buildWorkflowInput).mockReturnValue({
conversationHistory: undefined,
userName: 'John Doe',
isFollowUp: false,
isSlackFollowUp: false,
datasets: '',
});
vi.mocked(postProcessingWorkflow).mockResolvedValue(workflowOutput);
await runTask({ messageId });
// Verify Slack notification WAS sent (flagChat type)
expect(helpers.sendSlackNotification).toHaveBeenCalledWith({
organizationId: 'org-123',
userName: 'John Doe',
chatId: 'chat-123',
summaryTitle: 'Issue Title',
summaryMessage: 'Issues found',
toolCalled: 'noAssumptions',
});
});
it('should send Slack notification when major assumptions exist even with noIssuesFound', async () => {
const messageId = '123e4567-e89b-12d3-a456-426614174000';
const workflowOutput = {
flagChatResult: {
type: 'noIssuesFound' as const,
message: 'No issues',
},
assumptionsResult: {
toolCalled: 'listAssumptions',
assumptions: [
{
descriptiveTitle: 'Major assumption',
classification: 'fieldMapping',
explanation: 'Test',
label: 'major' as const,
},
],
},
formattedMessage: 'Major assumptions found',
};
vi.mocked(helpers.fetchMessageWithContext).mockResolvedValue({
id: messageId,
chatId: 'chat-123',
createdBy: 'user-123',
createdAt: new Date(),
rawLlmMessages: [] as any,
userName: 'John Doe',
organizationId: 'org-123',
});
vi.mocked(helpers.fetchPreviousPostProcessingMessages).mockResolvedValue([]);
vi.mocked(helpers.fetchUserDatasets).mockResolvedValue({
datasets: [],
total: 0,
page: 0,
pageSize: 1000,
});
vi.mocked(helpers.getExistingSlackMessageForChat).mockResolvedValue({ exists: false });
vi.mocked(helpers.sendSlackNotification).mockResolvedValue({
sent: true,
messageTs: 'msg-ts-123',
integrationId: 'int-123',
channelId: 'C123456',
});
vi.mocked(helpers.buildWorkflowInput).mockReturnValue({
conversationHistory: undefined,
userName: 'John Doe',
isFollowUp: false,
isSlackFollowUp: false,
datasets: '',
});
vi.mocked(postProcessingWorkflow).mockResolvedValue(workflowOutput);
await runTask({ messageId });
// Verify Slack notification WAS sent (major assumptions exist)
expect(helpers.sendSlackNotification).toHaveBeenCalled();
});
it('should handle undefined isSlackFollowUp in workflow input correctly', async () => {
const messageId = '123e4567-e89b-12d3-a456-426614174000';
const workflowOutput = {
flagChatResult: {
type: 'flagChat' as const,
summaryMessage: 'Issues found',
summaryTitle: 'Issue Title',
},
assumptionsResult: {
toolCalled: 'listAssumptions',
assumptions: [
{
descriptiveTitle: 'Major assumption',
classification: 'fieldMapping',
explanation: 'Test',
label: 'major' as const,
},
],
},
formattedMessage: 'Formatted message from workflow',
};
vi.mocked(helpers.fetchMessageWithContext).mockResolvedValue({
id: messageId,
chatId: 'chat-123',
createdBy: 'user-123',
createdAt: new Date(),
rawLlmMessages: [] as any,
userName: 'John Doe',
organizationId: 'org-123',
});
vi.mocked(helpers.fetchPreviousPostProcessingMessages).mockResolvedValue([]);
vi.mocked(helpers.fetchUserDatasets).mockResolvedValue({
datasets: [],
total: 0,
page: 0,
pageSize: 1000,
});
vi.mocked(helpers.getExistingSlackMessageForChat).mockResolvedValue({ exists: false });
vi.mocked(helpers.sendSlackNotification).mockResolvedValue({
sent: true,
messageTs: 'msg-ts-123',
integrationId: 'int-123',
channelId: 'C123456',
});
vi.mocked(helpers.buildWorkflowInput).mockReturnValue({
conversationHistory: undefined,
userName: 'John Doe',
isFollowUp: false,
isSlackFollowUp: undefined, // Critical: undefined case
datasets: '',
});
vi.mocked(postProcessingWorkflow).mockResolvedValue(workflowOutput);
await runTask({ messageId });
// Verify workflow was called with undefined isSlackFollowUp
expect(postProcessingWorkflow).toHaveBeenCalledWith({
conversationHistory: undefined,
userName: 'John Doe',
isFollowUp: false,
isSlackFollowUp: undefined,
datasets: '',
});
// Verify Slack notification WAS sent with formatted message
expect(helpers.sendSlackNotification).toHaveBeenCalledWith({
organizationId: 'org-123',
userName: 'John Doe',
chatId: 'chat-123',
summaryTitle: 'Issue Title',
summaryMessage: 'Issues found',
toolCalled: 'listAssumptions',
});
});
});
it('should return error result for database update failure', async () => {
const messageId = '123e4567-e89b-12d3-a456-426614174000';
const dbError = new Error('Database update failed');

View File

@ -234,11 +234,11 @@ export const messagePostProcessingTask: ReturnType<
// Step 7: Send Slack notification if conditions are met
let slackNotificationSent = false;
// Skip Slack notification if tool_called is "noIssuesFound" and there are no major assumptions
// Skip Slack notification if no issues were flagged and there are no major assumptions
const hasMajorAssumptions =
dbData.assumptions?.some((assumption) => assumption.label === 'major') ?? false;
const shouldSkipSlackNotification =
dbData.tool_called === 'noIssuesFound' && !hasMajorAssumptions;
validatedOutput.flagChatResult.type === 'noIssuesFound' && !hasMajorAssumptions;
try {
logger.log('Checking Slack notification conditions', {

View File

@ -16,6 +16,7 @@ import { useListReportVersionDropdownItems } from '@/components/features/version
import { Button } from '@/components/ui/buttons';
import {
createDropdownItem,
createDropdownItems,
Dropdown,
DropdownContent,
type IDropdownItem,
@ -23,12 +24,20 @@ import {
} from '@/components/ui/dropdown';
import { Dots, History, PenSparkle, ShareRight, Star } from '@/components/ui/icons';
import { Star as StarFilled } from '@/components/ui/icons/NucleoIconFilled';
import { Download4, Refresh } from '@/components/ui/icons/NucleoIconOutlined';
import {
Download4,
DuplicatePlus,
Redo,
Refresh,
Undo,
} from '@/components/ui/icons/NucleoIconOutlined';
import { useStartChatFromAsset } from '@/context/BusterAssets/useStartChatFromAsset';
import { useBusterNotifications } from '@/context/BusterNotifications';
import { useGetChatId } from '@/context/Chats/useGetChatId';
import { useReportPageExport } from '@/context/Reports/useReportPageExport';
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
import { useIsMac } from '@/hooks/usePlatform';
import { useEditorContext } from '@/layouts/AssetContainer/ReportAssetContainer';
import { canEdit, getIsEffectiveOwner } from '@/lib/share';
export const ReportThreeDotMenu = React.memo(
@ -46,9 +55,10 @@ export const ReportThreeDotMenu = React.memo(
const saveToLibrary = useSaveToLibrary({ reportId });
const favoriteItem = useFavoriteReportSelectMenu({ reportId });
const versionHistory = useVersionHistorySelectMenu({ reportId });
const undoRedo = useUndoRedo();
const duplicateReport = useDuplicateReportSelectMenu({ reportId });
// const verificationItem = useReportVerificationSelectMenu(); // Hidden - not supported yet
const refreshReportItem = useRefreshReportSelectMenu({ reportId });
// const duplicateReportItem = useDuplicateReportSelectMenu();
const { dropdownItem: downloadPdfItem, exportPdfContainer } = useDownloadPdfSelectMenu({
reportId,
});
@ -68,11 +78,13 @@ export const ReportThreeDotMenu = React.memo(
saveToLibrary,
favoriteItem,
{ type: 'divider' },
...undoRedo,
{ type: 'divider' },
versionHistory,
// verificationItem, // Hidden - not supported yet
{ type: 'divider' },
isEditor && refreshReportItem,
// duplicateReportItem,
duplicateReport,
downloadPdfItem,
];
}, [
@ -293,7 +305,6 @@ const useReportVerificationSelectMenu = (): IDropdownItem => {
// Refresh report with latest data
const useRefreshReportSelectMenu = ({ reportId }: { reportId: string }): IDropdownItem => {
const navigate = useNavigate();
const { onCreateFileClick, loading: isPending } = useStartChatFromAsset({
assetId: reportId,
assetType: 'report',
@ -374,3 +385,55 @@ const useDownloadPdfSelectMenu = ({
};
}, [reportId, exportReportAsPDF, cancelExport, ExportContainer]);
};
const useUndoRedo = (): IDropdownItems => {
const { editor } = useEditorContext();
const isMac = useIsMac();
const getEditor = () => {
if (!editor?.current) {
console.warn('Editor is not defined');
return;
}
return editor?.current;
};
return useMemo(
() =>
createDropdownItems([
{
label: 'Undo',
value: 'undo',
shortcut: isMac ? '⌘+Z' : 'Ctrl+Z',
icon: <Undo />,
onClick: () => {
const editorInstance = getEditor();
editorInstance?.undo();
},
},
{
label: 'Redo',
value: 'redo',
shortcut: isMac ? '⌘+⇧+Z' : 'Ctrl+⇧+Z',
icon: <Redo />,
onClick: () => {
const editorInstance = getEditor();
editorInstance?.redo();
},
},
]),
[isMac]
);
};
const useDuplicateReportSelectMenu = ({ reportId }: { reportId: string }): IDropdownItem => {
return useMemo(
() => ({
label: 'Duplicate',
value: 'duplicate-report',
icon: <DuplicatePlus />,
onClick: () => {
console.log('Duplicate report');
},
}),
[reportId]
);
};

View File

@ -99,7 +99,3 @@ export const EditorKit = ({
// Dnd
...DndKit({ containerRef }),
];
export type MyEditor = TPlateEditor<Value, ReturnType<typeof EditorKit>[number]>;
export const useEditor = () => useEditorRef<MyEditor>();

View File

@ -0,0 +1,5 @@
import type { Value } from 'platejs';
import type { TPlateEditor } from 'platejs/react';
import type { EditorKit } from './editor-kit';
export type BusterReportEditor = TPlateEditor<Value, ReturnType<typeof EditorKit>[number]>;

View File

@ -0,0 +1,4 @@
import { useEditorRef } from 'platejs/react';
import type { BusterReportEditor } from './types';
export const useEditor = () => useEditorRef<BusterReportEditor>();

View File

@ -3,7 +3,7 @@ import type { QueryClient } from '@tanstack/react-query';
import { Outlet } from '@tanstack/react-router';
import { z } from 'zod';
import { prefetchGetReport } from '@/api/buster_rest/reports';
import { ReportAssetContainer } from '../../../layouts/AssetContainer/ReportAssetContainer/ReportAssetContainer';
import { ReportAssetContainer } from '@/layouts/AssetContainer/ReportAssetContainer/ReportAssetContainer';
import { useGetReportParams } from '../../Reports/useGetReportParams';
export const validateSearch = z.object({

View File

@ -1,10 +1,28 @@
import { AnimatePresence, motion } from 'framer-motion';
import { ShimmerText } from '@/components/ui/typography/ShimmerText';
import { cn } from '@/lib/classMerge';
export const GeneratingContent = ({ className }: { messageId: string; className?: string }) => {
export const GeneratingContent = ({
className,
show,
}: {
messageId: string;
className?: string;
show: boolean;
}) => {
return (
<div className={cn('right-0 bottom-0 left-0 -mt-68', className)}>
<ShimmerText text="Generating..." className="text-lg" />
</div>
<AnimatePresence mode="wait">
{show && (
<motion.div
className={cn('right-0 bottom-0 left-0 absolute translate-y-[-255px]', className)}
initial={{ opacity: 0, y: -3 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -3 }}
transition={{ duration: 0.12, delay: 0.15 }}
>
<ShimmerText text="Generating..." fontSize={15} />
</motion.div>
)}
</AnimatePresence>
);
};

View File

@ -6,14 +6,18 @@ import { useTrackAndUpdateReportChanges } from '@/api/buster-electric/reports/ho
import DynamicReportEditor from '@/components/ui/report/DynamicReportEditor';
import type { IReportEditor } from '@/components/ui/report/ReportEditor';
import { ReportEditorSkeleton } from '@/components/ui/report/ReportEditorSkeleton';
import type { BusterReportEditor } from '@/components/ui/report/types';
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
import { useMount } from '@/hooks/useMount';
import { useEditorContext } from '@/layouts/AssetContainer/ReportAssetContainer';
import { cn } from '@/lib/utils';
import { chatQueryKeys } from '../../api/query_keys/chat';
import { useGetCurrentMessageId, useIsStreamingMessage } from '../../context/Chats';
import { GeneratingContent } from './GeneratingContent';
import { ReportPageHeader } from './ReportPageHeader';
const commonClassName = 'sm:px-[max(64px,calc(50%-350px))]';
export const ReportPageController: React.FC<{
reportId: string;
readOnly?: boolean;
@ -23,6 +27,7 @@ export const ReportPageController: React.FC<{
}> = React.memo(
({ reportId, readOnly = false, className = '', onReady: onReadyProp, mode = 'default' }) => {
const { data: report } = useGetReport({ id: reportId, versionNumber: undefined });
const { setEditor } = useEditorContext();
const isStreamingMessage = useIsStreamingMessage();
const messageId = useGetCurrentMessageId();
@ -45,7 +50,6 @@ export const ReportPageController: React.FC<{
const content = report?.content || '';
const showGeneratingContent = isThisReportBeingGenerated;
const commonClassName = 'sm:px-[max(64px,calc(50%-350px))]';
const { mutate: updateReport } = useUpdateReport();
@ -71,6 +75,11 @@ export const ReportPageController: React.FC<{
updateReport({ reportId, content });
});
const onReady = useMemoizedFn((editor: BusterReportEditor) => {
setEditor(editor);
onReadyProp?.(editor);
});
useTrackAndUpdateReportChanges({ reportId, subscribe: isStreamingMessage });
const containerRef = useRef<HTMLDivElement>(null);
@ -102,7 +111,7 @@ export const ReportPageController: React.FC<{
onValueChange={onChangeContent}
readOnly={readOnly || !report}
mode={mode}
onReady={onReadyProp}
onReady={onReady}
isStreaming={isStreamingMessage}
containerRef={containerRef}
preEditorChildren={
@ -115,9 +124,11 @@ export const ReportPageController: React.FC<{
/>
}
postEditorChildren={
showGeneratingContent ? (
<GeneratingContent messageId={messageId || ''} className={commonClassName} />
) : null
<GeneratingContent
messageId={messageId || ''}
className={commonClassName}
show={showGeneratingContent}
/>
}
/>
) : (

View File

@ -0,0 +1,87 @@
import { useMemo } from 'react';
import { isServer } from '@/lib/window';
/**
* Hook to detect if the user is on a Mac system
* @returns {boolean} true if the user is on a Mac, false otherwise (Windows, Linux, etc.)
*/
export const useIsMac = (): boolean => {
return useMemo(() => {
if (isServer) {
return false; // Server-side rendering fallback
}
// Check the user agent for Mac indicators
const userAgent = window.navigator.userAgent.toLowerCase();
return userAgent.includes('macintosh') || userAgent.includes('mac os');
}, []);
};
/**
* Hook to detect if the user is on a Windows system
* @returns {boolean} true if the user is on Windows, false otherwise
*/
export const useIsWindows = (): boolean => {
return useMemo(() => {
if (isServer) {
return false; // Server-side rendering fallback
}
// Check the user agent for Windows indicators
const userAgent = window.navigator.userAgent.toLowerCase();
return userAgent.includes('windows');
}, []);
};
/**
* Hook to get the platform type
* @returns {'mac' | 'windows' | 'other'} the detected platform
*/
export const usePlatform = (): 'mac' | 'windows' | 'other' => {
return useMemo(() => {
if (typeof window === 'undefined') {
return 'other'; // Server-side rendering fallback
}
const userAgent = window.navigator.userAgent.toLowerCase();
if (userAgent.includes('macintosh') || userAgent.includes('mac os')) {
return 'mac';
}
if (userAgent.includes('windows')) {
return 'windows';
}
return 'other';
}, []);
};
export const useBrowser = (): 'chrome' | 'firefox' | 'safari' | 'edge' | 'other' => {
return useMemo(() => {
if (isServer) {
return 'other'; // Server-side rendering fallback
}
const userAgent = window.navigator.userAgent.toLowerCase();
if (userAgent.includes('chrome')) {
return 'chrome';
}
if (userAgent.includes('firefox')) {
return 'firefox';
}
if (userAgent.includes('safari')) {
return 'safari';
}
if (userAgent.includes('edge')) {
return 'edge';
}
return 'other';
}, []);
};

View File

@ -39,7 +39,7 @@ const LazyMetricStoreDevtools = !import.meta.env.SSR
// The actual devtools component implementation
const TanstackDevtoolsImpl: React.FC = React.memo(() => {
useMount(() => {
console.log('🐓 Rendering TanstackDevtoolsImpl');
if (import.meta.env.PROD) console.log('🐓 Rendering TanstackDevtoolsImpl');
});
const isServerOrSSR = isServer && import.meta.env.SSR;

View File

@ -5,10 +5,10 @@ import { useGetReport } from '@/api/buster_rest/reports';
import { CreateChatButton } from '@/components/features/AssetLayout/CreateChatButton';
import { ShareReportButton } from '@/components/features/buttons/ShareReportButton';
import { ClosePageButton } from '@/components/features/chat/ClosePageButton';
import { ReportThreeDotMenu } from '@/components/features/reports/ReportThreeDotMenu';
import { useIsChatMode, useIsFileMode } from '@/context/Chats/useMode';
import { useIsReportReadOnly } from '@/context/Reports/useIsReportReadOnly';
import { getIsEffectiveOwner } from '@/lib/share';
import { ReportThreeDotMenu } from '../../../components/features/reports/ReportThreeDotMenu';
import { FileButtonContainer } from '../FileButtonContainer';
import { HideButtonContainer } from '../HideButtonContainer';
@ -26,7 +26,7 @@ export const ReportContainerHeaderButtons: React.FC<ReportContainerHeaderButtons
const { isViewingOldVersion } = useIsReportReadOnly({
reportId: reportId || '',
});
const { error: reportError, data: permission } = useGetReport(
const { data: permission } = useGetReport(
{ id: reportId },
{ select: useCallback((x: GetReportResponse) => x.permission, []) }
);

View File

@ -1,9 +1,12 @@
import { useState } from 'react';
import { useCallback, useRef, useState, useTransition } from 'react';
import { createContext, useContextSelector } from 'use-context-selector';
import type { BusterReportEditor } from '@/components/ui/report/types';
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
const useReportAssetContext = () => {
const [forceUpdate, startForceUpdate] = useTransition();
const [versionHistoryMode, setVersionHistoryMode] = useState<number | false>(false);
const editor = useRef<BusterReportEditor | null>(null);
const openReportVersionHistoryMode = useMemoizedFn((versionNumber: number) => {
setVersionHistoryMode(versionNumber);
@ -13,10 +16,23 @@ const useReportAssetContext = () => {
setVersionHistoryMode(false);
});
const setEditor = useMemoizedFn((editorInstance: BusterReportEditor) => {
if (!editorInstance) {
return;
}
startForceUpdate(() => {
editor.current = editorInstance;
});
});
return {
openReportVersionHistoryMode,
closeVersionHistoryMode,
versionHistoryMode,
setEditor,
forceUpdate,
editor,
};
};
@ -55,3 +71,25 @@ export const useReportVersionHistoryMode = () => {
closeVersionHistoryMode,
};
};
const stableSetEditorSelector = (x: ReturnType<typeof useReportAssetContext>) => x.setEditor;
const stableForceUpdateSelector = (x: ReturnType<typeof useReportAssetContext>) => x.forceUpdate;
export const useEditorContext = () => {
const forceUpdate = useContextSelector(ReportAssetContext, stableForceUpdateSelector);
const setEditor = useContextSelector(ReportAssetContext, stableSetEditorSelector);
const editor = useContextSelector(
ReportAssetContext,
useCallback((x) => x.editor, [forceUpdate])
);
if (!setEditor) {
console.warn(
'ReportAssetContext is not defined. useEditorContext must be used within a ReportAssetContextProvider.'
);
}
return {
editor,
setEditor,
};
};

View File

@ -0,0 +1,419 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { runMessagePostProcessingWorkflow } from './message-post-processing-workflow';
import type { PostProcessingWorkflowInput } from './message-post-processing-workflow';
// Mock the steps
vi.mock('../../steps/message-post-processing-steps/flag-chat-step/flag-chat-step', () => ({
runFlagChatStep: vi.fn(),
}));
vi.mock(
'../../steps/message-post-processing-steps/identify-assumptions-step/identify-assumptions-step',
() => ({
runIdentifyAssumptionsStep: vi.fn(),
})
);
vi.mock(
'../../steps/message-post-processing-steps/format-follow-up-message-step/format-follow-up-message-step',
() => ({
runFormatFollowUpMessageStep: vi.fn(),
})
);
vi.mock(
'../../steps/message-post-processing-steps/format-initial-message-step/format-initial-message-step',
() => ({
runFormatInitialMessageStep: vi.fn(),
})
);
import { runFlagChatStep } from '../../steps/message-post-processing-steps/flag-chat-step/flag-chat-step';
import { runFormatFollowUpMessageStep } from '../../steps/message-post-processing-steps/format-follow-up-message-step/format-follow-up-message-step';
import { runFormatInitialMessageStep } from '../../steps/message-post-processing-steps/format-initial-message-step/format-initial-message-step';
import { runIdentifyAssumptionsStep } from '../../steps/message-post-processing-steps/identify-assumptions-step/identify-assumptions-step';
describe('runMessagePostProcessingWorkflow', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Formatting logic edge cases', () => {
it('should format initial message when isSlackFollowUp is undefined', async () => {
// Setup
const input: PostProcessingWorkflowInput = {
userName: 'Test User',
datasets: 'test datasets',
isFollowUp: false,
isSlackFollowUp: undefined, // Critical test case
};
vi.mocked(runFlagChatStep).mockResolvedValue({
type: 'flagChat',
summaryMessage: 'Issues found',
summaryTitle: 'Test Issue',
});
vi.mocked(runIdentifyAssumptionsStep).mockResolvedValue({
toolCalled: 'listAssumptions',
assumptions: [
{
descriptiveTitle: 'Major assumption',
classification: 'fieldMapping',
explanation: 'Test explanation',
label: 'major',
},
],
});
vi.mocked(runFormatInitialMessageStep).mockResolvedValue({
summaryMessage: 'Formatted initial message',
summaryTitle: 'Initial Title',
});
// Execute
const result = await runMessagePostProcessingWorkflow(input);
// Verify - should call format initial message when isSlackFollowUp is undefined
expect(runFormatInitialMessageStep).toHaveBeenCalledWith({
userName: 'Test User',
flaggedIssues: 'Issues found',
majorAssumptions: [
{
descriptiveTitle: 'Major assumption',
classification: 'fieldMapping',
explanation: 'Test explanation',
label: 'major',
},
],
conversationHistory: undefined,
});
expect(result.formattedMessage).toBe('Formatted initial message');
});
it('should format initial message when isSlackFollowUp is false', async () => {
// Setup
const input: PostProcessingWorkflowInput = {
userName: 'Test User',
datasets: 'test datasets',
isFollowUp: false,
isSlackFollowUp: false, // Explicit false
};
vi.mocked(runFlagChatStep).mockResolvedValue({
type: 'flagChat',
summaryMessage: 'Issues found',
summaryTitle: 'Test Issue',
});
vi.mocked(runIdentifyAssumptionsStep).mockResolvedValue({
toolCalled: 'listAssumptions',
assumptions: [
{
descriptiveTitle: 'Major assumption',
classification: 'fieldMapping',
explanation: 'Test explanation',
label: 'major',
},
],
});
vi.mocked(runFormatInitialMessageStep).mockResolvedValue({
summaryMessage: 'Formatted initial message',
summaryTitle: 'Initial Title',
});
// Execute
const result = await runMessagePostProcessingWorkflow(input);
// Verify
expect(runFormatInitialMessageStep).toHaveBeenCalled();
expect(result.formattedMessage).toBe('Formatted initial message');
});
it('should format follow-up message when both isFollowUp and isSlackFollowUp are true', async () => {
// Setup
const input: PostProcessingWorkflowInput = {
userName: 'Test User',
datasets: 'test datasets',
isFollowUp: true,
isSlackFollowUp: true,
};
vi.mocked(runFlagChatStep).mockResolvedValue({
type: 'flagChat',
summaryMessage: 'Issues found',
summaryTitle: 'Test Issue',
});
vi.mocked(runIdentifyAssumptionsStep).mockResolvedValue({
toolCalled: 'listAssumptions',
assumptions: [
{
descriptiveTitle: 'Major assumption',
classification: 'fieldMapping',
explanation: 'Test explanation',
label: 'major',
},
],
});
vi.mocked(runFormatFollowUpMessageStep).mockResolvedValue({
summaryMessage: 'Formatted follow-up message',
summaryTitle: 'Follow-up Title',
});
// Execute
const result = await runMessagePostProcessingWorkflow(input);
// Verify
expect(runFormatFollowUpMessageStep).toHaveBeenCalled();
expect(runFormatInitialMessageStep).not.toHaveBeenCalled();
expect(result.formattedMessage).toBe('Formatted follow-up message');
});
it('should NOT format message when no major assumptions and no flagged issues', async () => {
// Setup
const input: PostProcessingWorkflowInput = {
userName: 'Test User',
datasets: 'test datasets',
isFollowUp: false,
isSlackFollowUp: false,
};
vi.mocked(runFlagChatStep).mockResolvedValue({
type: 'noIssuesFound',
message: 'No issues found',
});
vi.mocked(runIdentifyAssumptionsStep).mockResolvedValue({
toolCalled: 'noAssumptions',
assumptions: undefined,
});
// Execute
const result = await runMessagePostProcessingWorkflow(input);
// Verify - no formatting functions should be called
expect(runFormatInitialMessageStep).not.toHaveBeenCalled();
expect(runFormatFollowUpMessageStep).not.toHaveBeenCalled();
expect(result.formattedMessage).toBeUndefined();
});
it('should format message when major assumptions exist even without flagged issues', async () => {
// Setup
const input: PostProcessingWorkflowInput = {
userName: 'Test User',
datasets: 'test datasets',
isFollowUp: false,
isSlackFollowUp: false,
};
vi.mocked(runFlagChatStep).mockResolvedValue({
type: 'noIssuesFound',
message: 'No issues found',
});
vi.mocked(runIdentifyAssumptionsStep).mockResolvedValue({
toolCalled: 'listAssumptions',
assumptions: [
{
descriptiveTitle: 'Major assumption',
classification: 'fieldMapping',
explanation: 'Test explanation',
label: 'major',
},
],
});
vi.mocked(runFormatInitialMessageStep).mockResolvedValue({
summaryMessage: 'Major assumptions require attention',
summaryTitle: 'Assumptions Found',
});
// Execute
const result = await runMessagePostProcessingWorkflow(input);
// Verify - should format because of major assumptions
expect(runFormatInitialMessageStep).toHaveBeenCalled();
expect(result.formattedMessage).toBe('Major assumptions require attention');
});
it('should NOT format message with only minor assumptions', async () => {
// Setup
const input: PostProcessingWorkflowInput = {
userName: 'Test User',
datasets: 'test datasets',
isFollowUp: false,
isSlackFollowUp: false,
};
vi.mocked(runFlagChatStep).mockResolvedValue({
type: 'noIssuesFound',
message: 'No issues found',
});
vi.mocked(runIdentifyAssumptionsStep).mockResolvedValue({
toolCalled: 'listAssumptions',
assumptions: [
{
descriptiveTitle: 'Minor assumption',
classification: 'fieldMapping',
explanation: 'Test explanation',
label: 'minor',
},
],
});
// Execute
const result = await runMessagePostProcessingWorkflow(input);
// Verify - should NOT format because only minor assumptions
expect(runFormatInitialMessageStep).not.toHaveBeenCalled();
expect(runFormatFollowUpMessageStep).not.toHaveBeenCalled();
expect(result.formattedMessage).toBeUndefined();
});
});
describe('Result structure validation', () => {
it('should return correct structure for flagChat with assumptions', async () => {
const input: PostProcessingWorkflowInput = {
userName: 'Test User',
datasets: 'test datasets',
};
vi.mocked(runFlagChatStep).mockResolvedValue({
type: 'flagChat',
summaryMessage: 'Issues found',
summaryTitle: 'Test Issue',
});
vi.mocked(runIdentifyAssumptionsStep).mockResolvedValue({
toolCalled: 'listAssumptions',
assumptions: [
{
descriptiveTitle: 'Test assumption',
classification: 'fieldMapping',
explanation: 'Test explanation',
label: 'major',
},
],
});
vi.mocked(runFormatInitialMessageStep).mockResolvedValue({
summaryMessage: 'Formatted message',
summaryTitle: 'Title',
});
const result = await runMessagePostProcessingWorkflow(input);
expect(result).toEqual({
flagChatResult: {
type: 'flagChat',
summaryMessage: 'Issues found',
summaryTitle: 'Test Issue',
message: undefined,
},
assumptionsResult: {
toolCalled: 'listAssumptions',
assumptions: [
{
descriptiveTitle: 'Test assumption',
classification: 'fieldMapping',
explanation: 'Test explanation',
label: 'major',
},
],
},
formattedMessage: 'Formatted message',
});
});
it('should return correct structure for noIssuesFound with no assumptions', async () => {
const input: PostProcessingWorkflowInput = {
userName: 'Test User',
datasets: 'test datasets',
};
vi.mocked(runFlagChatStep).mockResolvedValue({
type: 'noIssuesFound',
message: 'All good',
});
vi.mocked(runIdentifyAssumptionsStep).mockResolvedValue({
toolCalled: 'noAssumptions',
assumptions: undefined,
});
const result = await runMessagePostProcessingWorkflow(input);
expect(result).toEqual({
flagChatResult: {
type: 'noIssuesFound',
summaryMessage: undefined,
summaryTitle: undefined,
message: 'All good',
},
assumptionsResult: {
toolCalled: 'noAssumptions',
assumptions: undefined,
},
formattedMessage: undefined,
});
});
});
describe('Edge cases', () => {
it('should handle all undefined optional fields', async () => {
const input: PostProcessingWorkflowInput = {
userName: 'Test User',
datasets: '',
conversationHistory: undefined,
isFollowUp: undefined,
isSlackFollowUp: undefined,
};
vi.mocked(runFlagChatStep).mockResolvedValue({
type: 'noIssuesFound',
message: 'No issues',
});
vi.mocked(runIdentifyAssumptionsStep).mockResolvedValue({
toolCalled: 'noAssumptions',
assumptions: undefined,
});
// Should not throw
const result = await runMessagePostProcessingWorkflow(input);
expect(result).toBeDefined();
expect(result.formattedMessage).toBeUndefined();
});
it('should handle empty datasets string', async () => {
const input: PostProcessingWorkflowInput = {
userName: 'Test User',
datasets: '',
};
vi.mocked(runFlagChatStep).mockResolvedValue({
type: 'noIssuesFound',
message: 'No issues',
});
vi.mocked(runIdentifyAssumptionsStep).mockResolvedValue({
toolCalled: 'noAssumptions',
assumptions: undefined,
});
const result = await runMessagePostProcessingWorkflow(input);
expect(runFlagChatStep).toHaveBeenCalledWith({
conversationHistory: undefined,
userName: 'Test User',
datasets: '',
});
});
});
});

View File

@ -91,8 +91,8 @@ export async function runMessagePostProcessingWorkflow(
conversationHistory: validatedInput.conversationHistory,
});
formattedMessage = followUpResult.summaryMessage;
} else if (validatedInput.isSlackFollowUp === false) {
// Format initial message
} else if (!validatedInput.isSlackFollowUp) {
// Format initial message (when isSlackFollowUp is false or undefined)
const initialResult = await runFormatInitialMessageStep({
userName: validatedInput.userName,
flaggedIssues,

View File

@ -7,6 +7,10 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./types": {
"types": "./dist/types.d.ts",
"default": "./dist/types.js"
},
"./helpers/*": {
"types": "./dist/helpers/*/index.d.ts",
"default": "./dist/helpers/*/index.js"

View File

@ -0,0 +1,30 @@
/**
* Type-only exports for message schemas
* This file provides types without triggering database connection
*/
// Export message schema types
export type {
ChatMessageReasoning_status,
ChatMessageResponseMessage,
ChatMessageReasoningMessage,
ChatMessageReasoningMessage_Text,
ChatMessageReasoningMessage_Files,
ChatMessageReasoningMessage_Pills,
ChatMessageReasoningMessage_File,
ChatMessageReasoningMessage_Pill,
ChatMessageReasoningMessage_PillContainer,
ChatMessageResponseMessage_FileMetadata,
ChatMessageResponseMessage_Text,
ChatMessageResponseMessage_File,
ReasoningFileType,
ResponseMessageFileType,
ReasoingMessage_ThoughtFileType,
} from './schemas/message-schemas';
// Export the schemas themselves (these are just objects, no side effects)
export {
StatusSchema,
ResponseMessageSchema,
ReasoningMessageSchema,
} from './schemas/message-schemas';

View File

@ -6,6 +6,7 @@ export * from './requests';
export * from './responses';
// Re-export message schemas from database package to maintain backward compatibility
// Using /types entry point to avoid triggering database connection
export {
StatusSchema,
ResponseMessageSchema,
@ -25,4 +26,4 @@ export {
type ReasoningFileType,
type ResponseMessageFileType,
type ReasoingMessage_ThoughtFileType,
} from '@buster/database';
} from '@buster/database/types';