From 2f8e70aaec3f39dd6e38c737d7821b3b80e942c8 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Tue, 9 Sep 2025 09:08:21 -0600 Subject: [PATCH 1/8] add undo redo --- .../features/reports/ReportThreeDotMenu.tsx | 57 +++++++++++++++++-- .../src/components/ui/report/editor-kit.ts | 4 -- apps/web/src/components/ui/report/types.ts | 5 ++ .../web/src/components/ui/report/useEditor.ts | 4 ++ .../reportLayoutServerAssetContext.tsx | 2 +- .../ReportPageController.tsx | 10 +++- .../tanstack-devtools-impl.tsx | 2 +- .../ReportContainerHeaderButtons.tsx | 4 +- .../ReportContextProvider.tsx | 51 ++++++++++++++++- 9 files changed, 125 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/components/ui/report/types.ts create mode 100644 apps/web/src/components/ui/report/useEditor.ts diff --git a/apps/web/src/components/features/reports/ReportThreeDotMenu.tsx b/apps/web/src/components/features/reports/ReportThreeDotMenu.tsx index 59386904b..cff8aa36d 100644 --- a/apps/web/src/components/features/reports/ReportThreeDotMenu.tsx +++ b/apps/web/src/components/features/reports/ReportThreeDotMenu.tsx @@ -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,19 @@ 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 { useEditorContext } from '@/layouts/AssetContainer/ReportAssetContainer'; import { canEdit, getIsEffectiveOwner } from '@/lib/share'; export const ReportThreeDotMenu = React.memo( @@ -46,9 +54,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 +77,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 +304,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 +384,42 @@ const useDownloadPdfSelectMenu = ({ }; }, [reportId, exportReportAsPDF, cancelExport, ExportContainer]); }; + +const useUndoRedo = (): IDropdownItems => { + const { editor, setEditor } = useEditorContext(); + return createDropdownItems([ + { + label: 'Undo', + value: 'undo', + icon: , + onClick: () => { + console.log('Undo'); + console.log(editor); + // editor?.tf.undo(); + }, + }, + { + label: 'Redo', + value: 'redo', + icon: , + onClick: () => { + console.log('Redo'); + // editor?.tf.redo(); + }, + }, + ]); +}; + +const useDuplicateReportSelectMenu = ({ reportId }: { reportId: string }): IDropdownItem => { + return useMemo( + () => ({ + label: 'Duplicate', + value: 'duplicate-report', + icon: , + onClick: () => { + console.log('Duplicate report'); + }, + }), + [reportId] + ); +}; diff --git a/apps/web/src/components/ui/report/editor-kit.ts b/apps/web/src/components/ui/report/editor-kit.ts index 78720ed00..01ac77fd8 100644 --- a/apps/web/src/components/ui/report/editor-kit.ts +++ b/apps/web/src/components/ui/report/editor-kit.ts @@ -99,7 +99,3 @@ export const EditorKit = ({ // Dnd ...DndKit({ containerRef }), ]; - -export type MyEditor = TPlateEditor[number]>; - -export const useEditor = () => useEditorRef(); diff --git a/apps/web/src/components/ui/report/types.ts b/apps/web/src/components/ui/report/types.ts new file mode 100644 index 000000000..c5a8bd4ee --- /dev/null +++ b/apps/web/src/components/ui/report/types.ts @@ -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[number]>; diff --git a/apps/web/src/components/ui/report/useEditor.ts b/apps/web/src/components/ui/report/useEditor.ts new file mode 100644 index 000000000..ac0ae521e --- /dev/null +++ b/apps/web/src/components/ui/report/useEditor.ts @@ -0,0 +1,4 @@ +import { useEditorRef } from 'platejs/react'; +import type { BusterReportEditor } from './types'; + +export const useEditor = () => useEditorRef(); diff --git a/apps/web/src/context/BusterAssets/report-server/reportLayoutServerAssetContext.tsx b/apps/web/src/context/BusterAssets/report-server/reportLayoutServerAssetContext.tsx index 29791b4dd..cceeec8f3 100644 --- a/apps/web/src/context/BusterAssets/report-server/reportLayoutServerAssetContext.tsx +++ b/apps/web/src/context/BusterAssets/report-server/reportLayoutServerAssetContext.tsx @@ -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({ diff --git a/apps/web/src/controllers/ReportPageControllers/ReportPageController.tsx b/apps/web/src/controllers/ReportPageControllers/ReportPageController.tsx index 3038bd5c9..e9c5063a6 100644 --- a/apps/web/src/controllers/ReportPageControllers/ReportPageController.tsx +++ b/apps/web/src/controllers/ReportPageControllers/ReportPageController.tsx @@ -6,8 +6,10 @@ 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'; @@ -23,6 +25,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(); @@ -71,6 +74,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(null); @@ -102,7 +110,7 @@ export const ReportPageController: React.FC<{ onValueChange={onChangeContent} readOnly={readOnly || !report} mode={mode} - onReady={onReadyProp} + onReady={onReady} isStreaming={isStreamingMessage} containerRef={containerRef} preEditorChildren={ diff --git a/apps/web/src/integrations/tanstack-dev-tools/tanstack-devtools-impl.tsx b/apps/web/src/integrations/tanstack-dev-tools/tanstack-devtools-impl.tsx index 770f1070f..1e4d7e9e5 100644 --- a/apps/web/src/integrations/tanstack-dev-tools/tanstack-devtools-impl.tsx +++ b/apps/web/src/integrations/tanstack-dev-tools/tanstack-devtools-impl.tsx @@ -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; diff --git a/apps/web/src/layouts/AssetContainer/ReportAssetContainer/ReportContainerHeaderButtons.tsx b/apps/web/src/layouts/AssetContainer/ReportAssetContainer/ReportContainerHeaderButtons.tsx index 4b3505b00..3a94b1d62 100644 --- a/apps/web/src/layouts/AssetContainer/ReportAssetContainer/ReportContainerHeaderButtons.tsx +++ b/apps/web/src/layouts/AssetContainer/ReportAssetContainer/ReportContainerHeaderButtons.tsx @@ -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 x.permission, []) } ); diff --git a/apps/web/src/layouts/AssetContainer/ReportAssetContainer/ReportContextProvider.tsx b/apps/web/src/layouts/AssetContainer/ReportAssetContainer/ReportContextProvider.tsx index bad2457e4..b13e47ef2 100644 --- a/apps/web/src/layouts/AssetContainer/ReportAssetContainer/ReportContextProvider.tsx +++ b/apps/web/src/layouts/AssetContainer/ReportAssetContainer/ReportContextProvider.tsx @@ -1,9 +1,17 @@ -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 [hasEditor, setHasEditor] = useState(false); const [versionHistoryMode, setVersionHistoryMode] = useState(false); + const editor = useRef(null); + const undo = useRef<(() => void) | null>(null); + const redo = useRef<(() => void) | null>(null); + + console.log('hasEditor', editor.current, !!undo.current, !!redo.current, hasEditor, forceUpdate); const openReportVersionHistoryMode = useMemoizedFn((versionNumber: number) => { setVersionHistoryMode(versionNumber); @@ -13,10 +21,29 @@ const useReportAssetContext = () => { setVersionHistoryMode(false); }); + const setEditor = useMemoizedFn((editor: BusterReportEditor) => { + if (!editor) { + return; + } + + editor.current = editor; + undo.current = editor.undo; + redo.current = editor.redo; + + startForceUpdate(() => { + setHasEditor(true); + console.log('setEditor2', editor.current); + console.log('editor.current', editor.current); + }); + }); + return { openReportVersionHistoryMode, closeVersionHistoryMode, versionHistoryMode, + setEditor, + hasEditor, + editor, }; }; @@ -55,3 +82,25 @@ export const useReportVersionHistoryMode = () => { closeVersionHistoryMode, }; }; + +const stableSetEditorSelector = (x: ReturnType) => x.setEditor; +const stableHasEditorSelector = (x: ReturnType) => x.hasEditor; +export const useEditorContext = () => { + const hasEditor = useContextSelector(ReportAssetContext, stableHasEditorSelector); + const setEditor = useContextSelector(ReportAssetContext, stableSetEditorSelector); + const editor = useContextSelector( + ReportAssetContext, + useCallback((x) => x.editor, [hasEditor]) + ); + + if (!setEditor) { + console.warn( + 'ReportAssetContext is not defined. useEditorContext must be used within a ReportAssetContextProvider.' + ); + } + + return { + editor, + setEditor, + }; +}; From 9f68517b1b03e61cf88d732e75c16f0d32aa5ea8 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Tue, 9 Sep 2025 09:11:16 -0600 Subject: [PATCH 2/8] Create editor global context --- .../features/reports/ReportThreeDotMenu.tsx | 54 +++++++++++-------- .../ReportContextProvider.tsx | 25 +++------ 2 files changed, 39 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/features/reports/ReportThreeDotMenu.tsx b/apps/web/src/components/features/reports/ReportThreeDotMenu.tsx index cff8aa36d..f6ce76608 100644 --- a/apps/web/src/components/features/reports/ReportThreeDotMenu.tsx +++ b/apps/web/src/components/features/reports/ReportThreeDotMenu.tsx @@ -386,28 +386,38 @@ const useDownloadPdfSelectMenu = ({ }; const useUndoRedo = (): IDropdownItems => { - const { editor, setEditor } = useEditorContext(); - return createDropdownItems([ - { - label: 'Undo', - value: 'undo', - icon: , - onClick: () => { - console.log('Undo'); - console.log(editor); - // editor?.tf.undo(); - }, - }, - { - label: 'Redo', - value: 'redo', - icon: , - onClick: () => { - console.log('Redo'); - // editor?.tf.redo(); - }, - }, - ]); + const { editor } = useEditorContext(); + const getEditor = () => { + if (!editor?.current) { + console.warn('Editor is not defined'); + return; + } + return editor?.current; + }; + return useMemo( + () => + createDropdownItems([ + { + label: 'Undo', + value: 'undo', + icon: , + onClick: () => { + const editorInstance = getEditor(); + editorInstance?.undo(); + }, + }, + { + label: 'Redo', + value: 'redo', + icon: , + onClick: () => { + const editorInstance = getEditor(); + editorInstance?.redo(); + }, + }, + ]), + [] + ); }; const useDuplicateReportSelectMenu = ({ reportId }: { reportId: string }): IDropdownItem => { diff --git a/apps/web/src/layouts/AssetContainer/ReportAssetContainer/ReportContextProvider.tsx b/apps/web/src/layouts/AssetContainer/ReportAssetContainer/ReportContextProvider.tsx index b13e47ef2..b8a714b9c 100644 --- a/apps/web/src/layouts/AssetContainer/ReportAssetContainer/ReportContextProvider.tsx +++ b/apps/web/src/layouts/AssetContainer/ReportAssetContainer/ReportContextProvider.tsx @@ -5,13 +5,8 @@ import { useMemoizedFn } from '@/hooks/useMemoizedFn'; const useReportAssetContext = () => { const [forceUpdate, startForceUpdate] = useTransition(); - const [hasEditor, setHasEditor] = useState(false); const [versionHistoryMode, setVersionHistoryMode] = useState(false); const editor = useRef(null); - const undo = useRef<(() => void) | null>(null); - const redo = useRef<(() => void) | null>(null); - - console.log('hasEditor', editor.current, !!undo.current, !!redo.current, hasEditor, forceUpdate); const openReportVersionHistoryMode = useMemoizedFn((versionNumber: number) => { setVersionHistoryMode(versionNumber); @@ -21,19 +16,13 @@ const useReportAssetContext = () => { setVersionHistoryMode(false); }); - const setEditor = useMemoizedFn((editor: BusterReportEditor) => { - if (!editor) { + const setEditor = useMemoizedFn((editorInstance: BusterReportEditor) => { + if (!editorInstance) { return; } - editor.current = editor; - undo.current = editor.undo; - redo.current = editor.redo; - startForceUpdate(() => { - setHasEditor(true); - console.log('setEditor2', editor.current); - console.log('editor.current', editor.current); + editor.current = editorInstance; }); }); @@ -42,7 +31,7 @@ const useReportAssetContext = () => { closeVersionHistoryMode, versionHistoryMode, setEditor, - hasEditor, + forceUpdate, editor, }; }; @@ -84,13 +73,13 @@ export const useReportVersionHistoryMode = () => { }; const stableSetEditorSelector = (x: ReturnType) => x.setEditor; -const stableHasEditorSelector = (x: ReturnType) => x.hasEditor; +const stableForceUpdateSelector = (x: ReturnType) => x.forceUpdate; export const useEditorContext = () => { - const hasEditor = useContextSelector(ReportAssetContext, stableHasEditorSelector); + const forceUpdate = useContextSelector(ReportAssetContext, stableForceUpdateSelector); const setEditor = useContextSelector(ReportAssetContext, stableSetEditorSelector); const editor = useContextSelector( ReportAssetContext, - useCallback((x) => x.editor, [hasEditor]) + useCallback((x) => x.editor, [forceUpdate]) ); if (!setEditor) { From 84245ee2668f5e79f6a70bf9fb05edb85552a88c Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Tue, 9 Sep 2025 09:19:28 -0600 Subject: [PATCH 3/8] use mac hot keys --- .../features/reports/ReportThreeDotMenu.tsx | 6 +- apps/web/src/hooks/usePlatform.ts | 87 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/hooks/usePlatform.ts diff --git a/apps/web/src/components/features/reports/ReportThreeDotMenu.tsx b/apps/web/src/components/features/reports/ReportThreeDotMenu.tsx index f6ce76608..f4c7c185d 100644 --- a/apps/web/src/components/features/reports/ReportThreeDotMenu.tsx +++ b/apps/web/src/components/features/reports/ReportThreeDotMenu.tsx @@ -36,6 +36,7 @@ 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'; @@ -387,6 +388,7 @@ const useDownloadPdfSelectMenu = ({ const useUndoRedo = (): IDropdownItems => { const { editor } = useEditorContext(); + const isMac = useIsMac(); const getEditor = () => { if (!editor?.current) { console.warn('Editor is not defined'); @@ -400,6 +402,7 @@ const useUndoRedo = (): IDropdownItems => { { label: 'Undo', value: 'undo', + shortcut: isMac ? '⌘+Z' : 'Ctrl+Z', icon: , onClick: () => { const editorInstance = getEditor(); @@ -409,6 +412,7 @@ const useUndoRedo = (): IDropdownItems => { { label: 'Redo', value: 'redo', + shortcut: isMac ? '⌘+⇧+Z' : 'Ctrl+⇧+Z', icon: , onClick: () => { const editorInstance = getEditor(); @@ -416,7 +420,7 @@ const useUndoRedo = (): IDropdownItems => { }, }, ]), - [] + [isMac] ); }; diff --git a/apps/web/src/hooks/usePlatform.ts b/apps/web/src/hooks/usePlatform.ts new file mode 100644 index 000000000..e82f0063f --- /dev/null +++ b/apps/web/src/hooks/usePlatform.ts @@ -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'; + }, []); +}; From 1344bfcebadb4e127f41797c9340c6bb389fb4d0 Mon Sep 17 00:00:00 2001 From: dal Date: Tue, 9 Sep 2025 09:22:14 -0600 Subject: [PATCH 4/8] Add Slack notification handling logic in message post-processing task - Implemented tests for Slack notification skip logic based on issue and assumption conditions. - Updated the main task to skip notifications when no issues are found and no major assumptions exist. - Enhanced workflow to handle undefined `isSlackFollowUp` correctly, ensuring proper message formatting and notification sending. --- .../message-post-processing.test.ts | 303 +++++++++++++ .../message-post-processing.ts | 4 +- .../message-post-processing-workflow.test.ts | 419 ++++++++++++++++++ .../message-post-processing-workflow.ts | 4 +- 4 files changed, 726 insertions(+), 4 deletions(-) create mode 100644 packages/ai/src/workflows/message-post-processing-workflow/message-post-processing-workflow.test.ts diff --git a/apps/trigger/src/tasks/message-post-processing/message-post-processing.test.ts b/apps/trigger/src/tasks/message-post-processing/message-post-processing.test.ts index b26dac7f5..a6ab58c8b 100644 --- a/apps/trigger/src/tasks/message-post-processing/message-post-processing.test.ts +++ b/apps/trigger/src/tasks/message-post-processing/message-post-processing.test.ts @@ -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'); diff --git a/apps/trigger/src/tasks/message-post-processing/message-post-processing.ts b/apps/trigger/src/tasks/message-post-processing/message-post-processing.ts index 6e214a41e..87cfd7d21 100644 --- a/apps/trigger/src/tasks/message-post-processing/message-post-processing.ts +++ b/apps/trigger/src/tasks/message-post-processing/message-post-processing.ts @@ -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', { diff --git a/packages/ai/src/workflows/message-post-processing-workflow/message-post-processing-workflow.test.ts b/packages/ai/src/workflows/message-post-processing-workflow/message-post-processing-workflow.test.ts new file mode 100644 index 000000000..299bf3f2a --- /dev/null +++ b/packages/ai/src/workflows/message-post-processing-workflow/message-post-processing-workflow.test.ts @@ -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: '', + }); + }); + }); +}); diff --git a/packages/ai/src/workflows/message-post-processing-workflow/message-post-processing-workflow.ts b/packages/ai/src/workflows/message-post-processing-workflow/message-post-processing-workflow.ts index e068cd2af..4322cbb11 100644 --- a/packages/ai/src/workflows/message-post-processing-workflow/message-post-processing-workflow.ts +++ b/packages/ai/src/workflows/message-post-processing-workflow/message-post-processing-workflow.ts @@ -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, From 42bf5859c8861db9782f8e9bc80ad9573a5f2f37 Mon Sep 17 00:00:00 2001 From: dal Date: Tue, 9 Sep 2025 09:27:03 -0600 Subject: [PATCH 5/8] fix on files --- apps/cli/src/components/banner.tsx | 10 ++--- apps/cli/src/components/simple-big-text.tsx | 41 +++++++++++++++++++++ apps/cli/src/index.tsx | 2 +- packages/database/package.json | 4 ++ packages/database/src/types.ts | 30 +++++++++++++++ packages/server-shared/src/chats/index.ts | 3 +- 6 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 apps/cli/src/components/simple-big-text.tsx create mode 100644 packages/database/src/types.ts diff --git a/apps/cli/src/components/banner.tsx b/apps/cli/src/components/banner.tsx index 1ee45f0d0..06c4bf9a5 100644 --- a/apps/cli/src/components/banner.tsx +++ b/apps/cli/src/components/banner.tsx @@ -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 = ( <> - - - + {showSubtitle && ( - + Welcome to Buster )} diff --git a/apps/cli/src/components/simple-big-text.tsx b/apps/cli/src/components/simple-big-text.tsx new file mode 100644 index 000000000..0ffae4670 --- /dev/null +++ b/apps/cli/src/components/simple-big-text.tsx @@ -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 = { + 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 {lines.join('\n')}; +} diff --git a/apps/cli/src/index.tsx b/apps/cli/src/index.tsx index a75b9b277..d75a4bf97 100644 --- a/apps/cli/src/index.tsx +++ b/apps/cli/src/index.tsx @@ -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 diff --git a/packages/database/package.json b/packages/database/package.json index 9c8f8be0f..27a4fe381 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -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" diff --git a/packages/database/src/types.ts b/packages/database/src/types.ts new file mode 100644 index 000000000..27f908a4d --- /dev/null +++ b/packages/database/src/types.ts @@ -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'; diff --git a/packages/server-shared/src/chats/index.ts b/packages/server-shared/src/chats/index.ts index 9b4a4d7e5..d76e6380d 100644 --- a/packages/server-shared/src/chats/index.ts +++ b/packages/server-shared/src/chats/index.ts @@ -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'; From 79a9d0e978e7cd2938fcbc62d45b2a264742af2d Mon Sep 17 00:00:00 2001 From: dal Date: Tue, 9 Sep 2025 09:36:53 -0600 Subject: [PATCH 6/8] Optimize CLI binary build process by adding --minify flag for production builds --- .github/workflows/cli-release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 1501e2643..a47aea497 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -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 From cadbb3de77395ec200b885befe2e4d6724c1f378 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Tue, 9 Sep 2025 09:50:27 -0600 Subject: [PATCH 7/8] Add better generating content --- .../GeneratingContent.tsx | 26 ++++++++++++++++--- .../ReportPageController.tsx | 13 ++++++---- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/apps/web/src/controllers/ReportPageControllers/GeneratingContent.tsx b/apps/web/src/controllers/ReportPageControllers/GeneratingContent.tsx index 2526be54d..cb0174ec8 100644 --- a/apps/web/src/controllers/ReportPageControllers/GeneratingContent.tsx +++ b/apps/web/src/controllers/ReportPageControllers/GeneratingContent.tsx @@ -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 ( -
- -
+ + {show && ( + + + + )} + ); }; diff --git a/apps/web/src/controllers/ReportPageControllers/ReportPageController.tsx b/apps/web/src/controllers/ReportPageControllers/ReportPageController.tsx index e9c5063a6..b8390e77e 100644 --- a/apps/web/src/controllers/ReportPageControllers/ReportPageController.tsx +++ b/apps/web/src/controllers/ReportPageControllers/ReportPageController.tsx @@ -16,6 +16,8 @@ import { useGetCurrentMessageId, useIsStreamingMessage } from '../../context/Cha 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; @@ -47,8 +49,7 @@ export const ReportPageController: React.FC<{ }, [currentMessage, isStreamingMessage, messageId, reportId]); const content = report?.content || ''; - const showGeneratingContent = isThisReportBeingGenerated; - const commonClassName = 'sm:px-[max(64px,calc(50%-350px))]'; + const showGeneratingContent = isThisReportBeingGenerated || true; const { mutate: updateReport } = useUpdateReport(); @@ -123,9 +124,11 @@ export const ReportPageController: React.FC<{ /> } postEditorChildren={ - showGeneratingContent ? ( - - ) : null + } /> ) : ( From fa173b726a56043dc248eb0fdf46d46c7b532fd2 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Tue, 9 Sep 2025 09:51:26 -0600 Subject: [PATCH 8/8] Update ReportPageController.tsx --- .../controllers/ReportPageControllers/ReportPageController.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/controllers/ReportPageControllers/ReportPageController.tsx b/apps/web/src/controllers/ReportPageControllers/ReportPageController.tsx index b8390e77e..31ed757db 100644 --- a/apps/web/src/controllers/ReportPageControllers/ReportPageController.tsx +++ b/apps/web/src/controllers/ReportPageControllers/ReportPageController.tsx @@ -49,7 +49,7 @@ export const ReportPageController: React.FC<{ }, [currentMessage, isStreamingMessage, messageId, reportId]); const content = report?.content || ''; - const showGeneratingContent = isThisReportBeingGenerated || true; + const showGeneratingContent = isThisReportBeingGenerated; const { mutate: updateReport } = useUpdateReport();