From c91e5bb6bcfb96ffb24d806f6d7133a33808ad8c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 22 Aug 2025 02:30:50 +0000 Subject: [PATCH 1/9] feat: integrate useAutoScroll hook with ReportEditor for streaming content - Add useAutoScroll hook to ReportEditor component - Configure auto-scroll to be enabled only when isStreaming is true - Add ref forwarding to EditorContainer for scroll container access - Hook observes DOM mutations from StreamContentPlugin updates - Maintains user scroll interaction handling (disable when scrolling up) Co-Authored-By: nate@buster.so --- .../components/ui/report/EditorContainer.tsx | 31 +++++++++++-------- .../src/components/ui/report/ReportEditor.tsx | 9 ++++++ 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/ui/report/EditorContainer.tsx b/apps/web/src/components/ui/report/EditorContainer.tsx index c0a5fbcb6..b5c44a3f3 100644 --- a/apps/web/src/components/ui/report/EditorContainer.tsx +++ b/apps/web/src/components/ui/report/EditorContainer.tsx @@ -1,6 +1,7 @@ import { cn } from '@/lib/utils'; import { PlateContainer } from 'platejs/react'; import { cva, type VariantProps } from 'class-variance-authority'; +import React from 'react'; interface EditorContainerProps { className?: string; @@ -38,23 +39,27 @@ const editorContainerVariants = cva( } ); -export function EditorContainer({ - className, - variant, - disabled, - readonly, - ...props -}: React.ComponentProps<'div'> & - VariantProps & - EditorContainerProps) { +export const EditorContainer = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & + VariantProps & + EditorContainerProps +>(({ className, variant, disabled, readonly, children, ...htmlProps }, ref) => { return ( - + {...htmlProps} + > + + {children} + + ); -} +}); + +EditorContainer.displayName = 'EditorContainer'; diff --git a/apps/web/src/components/ui/report/ReportEditor.tsx b/apps/web/src/components/ui/report/ReportEditor.tsx index abeedd8ce..dd7428f03 100644 --- a/apps/web/src/components/ui/report/ReportEditor.tsx +++ b/apps/web/src/components/ui/report/ReportEditor.tsx @@ -4,6 +4,7 @@ import type { Value, AnyPluginConfig } from 'platejs'; import { Plate, type TPlateEditor } from 'platejs/react'; import React, { useImperativeHandle, useRef } from 'react'; import { useDebounceFn, useMemoizedFn } from '@/hooks'; +import { useAutoScroll } from '@/hooks/useAutoScroll'; import { cn } from '@/lib/utils'; import { Editor } from './Editor'; import { EditorContainer } from './EditorContainer'; @@ -65,6 +66,13 @@ export const ReportEditor = React.memo( ) => { // Initialize the editor instance using the custom useEditor hook const isReady = useRef(false); + const editorContainerRef = useRef(null); + + const { isAutoScrollEnabled } = useAutoScroll(editorContainerRef, { + enabled: isStreaming, + bottomThreshold: 50, + observeSubTree: true + }); // readOnly = true; // isStreaming = true; @@ -128,6 +136,7 @@ export const ReportEditor = React.memo( readOnly={readOnly || isStreaming} onValueChange={onValueChangeDebounced}> Date: Fri, 22 Aug 2025 03:54:00 +0000 Subject: [PATCH 2/9] web(report): use Tailwind border-border for editor divider (hr) to respect global theme --- apps/web/src/components/ui/report/elements/HrNode.tsx | 2 +- apps/web/src/components/ui/report/elements/HrNodeStatic.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ui/report/elements/HrNode.tsx b/apps/web/src/components/ui/report/elements/HrNode.tsx index 04e963cea..af027b000 100644 --- a/apps/web/src/components/ui/report/elements/HrNode.tsx +++ b/apps/web/src/components/ui/report/elements/HrNode.tsx @@ -18,7 +18,7 @@ export function HrElement(props: PlateElementProps) {

-
+
{props.children} From 6818c4657831fb9db53e1276dce1477e0251c758 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Thu, 21 Aug 2025 22:11:07 -0600 Subject: [PATCH 3/9] rename prop --- .../src/components/ui/report/ReportEditor.tsx | 6 +++--- .../ReportPageController.tsx | 19 ++++++++++--------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/ui/report/ReportEditor.tsx b/apps/web/src/components/ui/report/ReportEditor.tsx index 539560758..1c4589417 100644 --- a/apps/web/src/components/ui/report/ReportEditor.tsx +++ b/apps/web/src/components/ui/report/ReportEditor.tsx @@ -30,7 +30,7 @@ interface ReportEditorProps { onReady?: (editor: IReportEditor) => void; id?: string; mode?: 'export' | 'default'; - children?: React.ReactNode; + preEditorChildren?: React.ReactNode; postEditorChildren?: React.ReactNode; } @@ -60,7 +60,7 @@ export const ReportEditor = React.memo( useFixedToolbarKit = false, readOnly = false, isStreaming = false, - children, + preEditorChildren, postEditorChildren }, ref @@ -135,7 +135,7 @@ export const ReportEditor = React.memo( variant={variant} readOnly={readOnly} className={cn('editor-container relative overflow-auto', containerClassName)}> - {children} + {preEditorChildren} + } postEditorChildren={ showGeneratingContent ? ( ) : null - }> - - + }> ) : ( )} From b29fb18a3cbc88309b88be61e5b1139a02eacdca Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Thu, 21 Aug 2025 22:17:34 -0600 Subject: [PATCH 4/9] scroll to bottom button --- .../ui/buttons/ScrollToBottomButton.tsx} | 12 ++++---- .../src/components/ui/report/ReportEditor.tsx | 29 +++++++++++++++---- .../ui/report/elements/StreamingText.tsx | 2 +- .../ReasoningController.tsx | 4 +-- .../ReportPageController.tsx | 2 -- apps/web/src/styles/tailwindAnimations.css | 11 ++----- 6 files changed, 36 insertions(+), 24 deletions(-) rename apps/web/src/{controllers/ReasoningController/ReasoningScrollToBottom.tsx => components/ui/buttons/ScrollToBottomButton.tsx} (77%) diff --git a/apps/web/src/controllers/ReasoningController/ReasoningScrollToBottom.tsx b/apps/web/src/components/ui/buttons/ScrollToBottomButton.tsx similarity index 77% rename from apps/web/src/controllers/ReasoningController/ReasoningScrollToBottom.tsx rename to apps/web/src/components/ui/buttons/ScrollToBottomButton.tsx index 923579c8a..b094d6f24 100644 --- a/apps/web/src/controllers/ReasoningController/ReasoningScrollToBottom.tsx +++ b/apps/web/src/components/ui/buttons/ScrollToBottomButton.tsx @@ -3,18 +3,20 @@ import { ChevronDown } from '@/components/ui/icons'; import { AppTooltip } from '@/components/ui/tooltip'; import { cn } from '@/lib/classMerge'; -export const ReasoningScrollToBottom: React.FC<{ +export const ScrollToBottomButton: React.FC<{ isAutoScrollEnabled: boolean; scrollToBottom: () => void; -}> = React.memo(({ isAutoScrollEnabled, scrollToBottom }) => { + className?: string; +}> = React.memo(({ isAutoScrollEnabled, scrollToBottom, className }) => { return (
- diff --git a/apps/web/src/controllers/ReportPageControllers/ReportPageController.tsx b/apps/web/src/controllers/ReportPageControllers/ReportPageController.tsx index ca675b764..e5badb834 100644 --- a/apps/web/src/controllers/ReportPageControllers/ReportPageController.tsx +++ b/apps/web/src/controllers/ReportPageControllers/ReportPageController.tsx @@ -10,9 +10,7 @@ import { type IReportEditor } from '@/components/ui/report/ReportEditor'; import { ReportEditorSkeleton } from '@/components/ui/report/ReportEditorSkeleton'; import { useChatIndividualContextSelector } from '@/layouts/ChatLayout/ChatContext'; import { useTrackAndUpdateReportChanges } from '@/api/buster-electric/reports/hooks'; -import { ShimmerText } from '@/components/ui/typography/ShimmerText'; import { GeneratingContent } from './GeneratingContent'; -import { useHotkeys } from 'react-hotkeys-hook'; export const ReportPageController: React.FC<{ reportId: string; diff --git a/apps/web/src/styles/tailwindAnimations.css b/apps/web/src/styles/tailwindAnimations.css index ff9a9458b..0a86ace6c 100644 --- a/apps/web/src/styles/tailwindAnimations.css +++ b/apps/web/src/styles/tailwindAnimations.css @@ -60,21 +60,16 @@ } } -/* - highlightFade animation: - - Animates a highlight background and a bottom border only (no outline). - - The border is only on the bottom, not using outline. -*/ @keyframes highlightFade { 0% { /* Use highlight background, fallback to brand, then yellow */ background-color: var(--color-highlight-background, var(--color-purple-100, yellow)); - /* Only bottom border is visible at start */ - border-bottom: 1px solid var(--color-highlight-border, var(--color-purple-200, yellow)); + /* Use box-shadow instead of border - doesn't take up space */ + box-shadow: 0 1.5px 0 0 var(--color-highlight-border, var(--color-purple-300, yellow)); } 100% { background-color: var(--color-highlight-to-background, transparent); - border-bottom: 0px solid var(--color-highlight-to-border, transparent); + box-shadow: 0 0 0 0 var(--color-highlight-to-border, transparent); } } From eadb3389cd8adde1df9b50eaec30a25a6ddc545f Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Thu, 21 Aug 2025 22:26:25 -0600 Subject: [PATCH 5/9] Update EditorContainer.tsx --- apps/web/src/components/ui/report/EditorContainer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/report/EditorContainer.tsx b/apps/web/src/components/ui/report/EditorContainer.tsx index 4b53902e6..3b9087267 100644 --- a/apps/web/src/components/ui/report/EditorContainer.tsx +++ b/apps/web/src/components/ui/report/EditorContainer.tsx @@ -10,7 +10,7 @@ interface EditorContainerProps { } const editorContainerVariants = cva( - 'relative w-full cursor-text bg-transparent caret-primary select-text selection:bg-brand/15 focus-visible:outline-none [&_.slate-selection-area]:z-50 [&_.slate-selection-area]:border [&_.slate-selection-area]:border-brand/25 [&_.slate-selection-area]:bg-brand/15', + 'relative w-full cursor-text bg-transparent select-text selection:bg-brand/15 focus-visible:outline-none [&_.slate-selection-area]:z-50 [&_.slate-selection-area]:border [&_.slate-selection-area]:border-brand/25 [&_.slate-selection-area]:bg-brand/15', { variants: { From b2797ee7590d93c2e66840bedf0c8163f903f749 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Thu, 21 Aug 2025 22:28:03 -0600 Subject: [PATCH 6/9] Update MetricContent.tsx --- .../ui/report/elements/MetricElement/MetricContent.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/report/elements/MetricElement/MetricContent.tsx b/apps/web/src/components/ui/report/elements/MetricElement/MetricContent.tsx index cf4f85262..4b1f0b662 100644 --- a/apps/web/src/components/ui/report/elements/MetricElement/MetricContent.tsx +++ b/apps/web/src/components/ui/report/elements/MetricElement/MetricContent.tsx @@ -25,11 +25,15 @@ export const MetricContent = React.memo( const reportId = useChatLayoutContextSelector((x) => x.reportId) || ''; const reportVersionNumber = useChatLayoutContextSelector((x) => x.reportVersionNumber); const ref = useRef(null); + const hasBeenInViewport = useRef(false); const [inViewport] = useInViewport(ref, { threshold: 0.33 }); - const renderChart = inViewport || isExportMode; + if (inViewport && !hasBeenInViewport.current) { + hasBeenInViewport.current = true; + } + const renderChart = inViewport || isExportMode || hasBeenInViewport.current; const { data: metric, From eb52262bcde814d6ab88c7671503471d6af1eadd Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Thu, 21 Aug 2025 22:30:37 -0600 Subject: [PATCH 7/9] Update next.config.mjs --- apps/web/next.config.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index e7b1561be..9b184f35a 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -45,7 +45,8 @@ const createCspHeader = (isEmbed = false) => { ? `frame-ancestors 'self' *` : "frame-ancestors 'none'", // Frame sources - allow embeds from accepted domains - "frame-src 'self' https://vercel.live https://*.twitter.com https://twitter.com https://*.x.com https://x.com https://*.youtube.com https://youtube.com https://*.youtube-nocookie.com https://youtube-nocookie.com https://*.youtu.be https://youtu.be https://*.vimeo.com https://vimeo.com ${publicUrlOrigin}", + // Escape publicUrlOrigin to ensure it is a valid CSP source value + `frame-src 'self' https://vercel.live https://*.twitter.com https://twitter.com https://*.x.com https://x.com https://*.youtube.com https://youtube.com https://*.youtube-nocookie.com https://youtube-nocookie.com https://*.youtu.be https://youtu.be https://*.vimeo.com https://vimeo.com ${publicUrlOrigin}`, // Connect sources for API calls (() => { const connectSources = [ From c21d84a5d2612458daf4888f4a6c3f034d5ba79a Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Thu, 21 Aug 2025 22:59:16 -0600 Subject: [PATCH 8/9] fixed bug with metric and a callout --- .../src/app/test/report-playground/page.tsx | 218 ------------------ .../ui/report/ReportEditor.stories.tsx | 1 - .../src/components/ui/report/ReportEditor.tsx | 1 - .../markdown-kit/callout-serializer.ts | 29 ++- .../markdown-kit/platejs-conversion.test.ts | 29 ++- .../modify-reports-transform-helper.ts | 8 +- 6 files changed, 53 insertions(+), 233 deletions(-) delete mode 100644 apps/web/src/app/test/report-playground/page.tsx diff --git a/apps/web/src/app/test/report-playground/page.tsx b/apps/web/src/app/test/report-playground/page.tsx deleted file mode 100644 index fdaffec49..000000000 --- a/apps/web/src/app/test/report-playground/page.tsx +++ /dev/null @@ -1,218 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import DynamicReportEditor from '@/components/ui/report/DynamicReportEditor'; -import type { IReportEditor } from '@/components/ui/report/ReportEditor'; -import { AppSplitter } from '@/components/ui/layouts/AppSplitter'; -import { useMount } from '@/hooks'; - -export default function ReportPlayground() { - // 150 lines of markdown about Red Rising, the sci-fi series by Pierce Brown. - // This content is used as the initial value for the report editor playground. - - const commonClassName = 'sm:px-[max(64px,calc(50%-350px))]'; - const onChangeContent = (value: string) => { - console.log(value); - }; - const readOnly = false; - const mode = 'default'; - const onReadyProp = (editor: IReportEditor) => { - console.log(editor); - }; - const isStreamingMessage = false; - - const [content, setContent] = useState(''); - - useMount(() => { - setTimeout(() => { - setContent(contentOld); - }, 1); - }); - - return ( -
- -
- ); -} - -const contentOld = ` -# Red Rising: A Deep Dive into Pierce Brown's Dystopian Epic - -Red Rising is a science fiction series by Pierce Brown that has captivated readers with its blend of dystopian intrigue, political machinations, and relentless action. Set in a future where society is rigidly divided by color-coded castes, the story follows Darrow, a lowborn Red, as he infiltrates the ruling Golds to spark a revolution. - ---- - -## Table of Contents - -1. [Introduction](#introduction) -2. [The World of Red Rising](#the-world-of-red-rising) -3. [The Color Hierarchy](#the-color-hierarchy) -4. [Main Characters](#main-characters) -5. [Plot Overview](#plot-overview) -6. [Themes](#themes) -7. [The Institute](#the-institute) -8. [Political Intrigue](#political-intrigue) -9. [Technology and Society](#technology-and-society) -10. [The Sons of Ares](#the-sons-of-ares) -11. [The Golds](#the-golds) -12. [The Rebellion](#the-rebellion) -13. [Friendship and Betrayal](#friendship-and-betrayal) -14. [The Role of Family](#the-role-of-family) -15. [Violence and Sacrifice](#violence-and-sacrifice) -16. [The Sequel Series](#the-sequel-series) -17. [Critical Reception](#critical-reception) -18. [Adaptations](#adaptations) -19. [Conclusion](#conclusion) - ---- - -## Introduction - -Red Rising is more than just a dystopian adventure; it's a meditation on power, identity, and the cost of revolution. Pierce Brown crafts a world that is both familiar and alien, drawing on classical influences and modern anxieties. - ---- - -## The World of Red Rising - -The series is set on Mars and other planets, terraformed and colonized by humanity. Society is organized into a strict hierarchy, with each Color assigned specific roles. - ---- - -## The Color Hierarchy - -- **Golds**: The ruling elite, genetically engineered for strength and intelligence. -- **Silvers**: Financiers and administrators. -- **Coppers**: Bureaucrats and record-keepers. -- **Blues**: Pilots and navigators. -- **Greens**: Programmers and technical experts. -- **Yellows**: Doctors and scientists. -- **Oranges**: Mechanics and engineers. -- **Violets**: Artists and designers. -- **Obsidians**: Warriors, bred for battle. -- **Grays**: Soldiers and police. -- **Browns**: Servants. -- **Pinks**: Courtesans and companions. -- **Reds**: Miners, the lowest caste, toiling beneath the surface. - ---- - -## Main Characters - -- **Darrow of Lykos**: The protagonist, a Red who becomes a Gold. -- **Eo**: Darrow's wife, whose death inspires the rebellion. -- **Sevro au Barca**: Darrow's loyal and unpredictable friend. -- **Mustang (Virginia au Augustus)**: A Gold with a conscience, Darrow's ally and love interest. -- **The Jackal (Adrius au Augustus)**: Mustang's brother, a cunning and ruthless adversary. -- **Cassius au Bellona**: Darrow's friend-turned-rival. - ---- - -## Plot Overview - -Darrow, a Helldiver in the Martian mines, discovers that the surface is already habitable and that the Reds have been lied to for generations. After Eo's execution, Darrow is recruited by the Sons of Ares to infiltrate the Golds. He undergoes a painful transformation and enters the Institute, where he must survive brutal trials and political games. - ---- - -## Themes - -- **Class Struggle**: The series explores the consequences of rigid social hierarchies. -- **Identity**: Darrow's transformation raises questions about selfhood and authenticity. -- **Revolution**: The cost and necessity of rebellion are central to the narrative. -- **Loyalty and Betrayal**: Friendships are tested in the crucible of war. - ---- - -## The Institute - -A brutal training ground for young Golds, the Institute is a microcosm of the larger society. Here, Darrow must lead, fight, and outwit his peers to survive. - ---- - -## Political Intrigue - -Red Rising is rife with shifting alliances, betrayals, and power plays. The Golds' society is as cutthroat as any battlefield. - ---- - -## Technology and Society - -From gravity manipulation to genetic engineering, technology shapes every aspect of life. Yet, ancient traditions and rituals persist. - ---- - -## The Sons of Ares - -A secret organization dedicated to overthrowing the Golds, the Sons of Ares are both idealistic and ruthless. - ---- - -## The Golds - -Engineered to be superior, the Golds are both admirable and monstrous. Their society values strength, cunning, and beauty above all. - ---- - -## The Rebellion - -Darrow's journey is the spark that ignites a galaxy-wide rebellion, challenging the very foundations of society. - ---- - -## Friendship and Betrayal - -Alliances are fragile, and trust is a rare commodity. Darrow's relationships are tested at every turn. - ---- - -## The Role of Family - -Family, both biological and chosen, is a recurring motif. Darrow's love for Eo and his loyalty to his friends drive much of the plot. - ---- - -## Violence and Sacrifice - -The series does not shy away from the brutality of revolution. Sacrifice is a constant companion. - ---- - -## The Sequel Series - -The original trilogy is followed by a new series, expanding the scope and stakes of the story. - ---- - -## Critical Reception - -Red Rising has been praised for its world-building, complex characters, and relentless pacing. Critics have compared it to classics like *The Hunger Games* and *Ender's Game*. - ---- - -## Adaptations - -Film and television adaptations have been in development, though none have yet reached the screen. - ---- - -## Conclusion - -Red Rising is a thrilling, thought-provoking saga that challenges readers to question the world around them. Its blend of action, philosophy, and heart ensures its place among the greats of modern science fiction. - ---- - -*“Break the chains, my love.”* - - `.trim(); diff --git a/apps/web/src/components/ui/report/ReportEditor.stories.tsx b/apps/web/src/components/ui/report/ReportEditor.stories.tsx index c889dd610..95cc27e7f 100644 --- a/apps/web/src/components/ui/report/ReportEditor.stories.tsx +++ b/apps/web/src/components/ui/report/ReportEditor.stories.tsx @@ -56,7 +56,6 @@ const meta = { args: { placeholder: 'Start typing...', readOnly: false, - disabled: false, variant: 'default' } } satisfies Meta; diff --git a/apps/web/src/components/ui/report/ReportEditor.tsx b/apps/web/src/components/ui/report/ReportEditor.tsx index 9e6f66da9..e135ed569 100644 --- a/apps/web/src/components/ui/report/ReportEditor.tsx +++ b/apps/web/src/components/ui/report/ReportEditor.tsx @@ -12,7 +12,6 @@ import { ThemeWrapper } from './ThemeWrapper/ThemeWrapper'; import { useReportEditor } from './useReportEditor'; import type { ReportElementsWithIds, ReportElementWithId } from '@buster/server-shared/reports'; import { platejsToMarkdown } from './plugins/markdown-kit/platejs-conversions'; -import { ShimmerText } from '@/components/ui/typography/ShimmerText'; import { ScrollToBottomButton } from '../buttons/ScrollToBottomButton'; interface ReportEditorProps { diff --git a/apps/web/src/components/ui/report/plugins/markdown-kit/callout-serializer.ts b/apps/web/src/components/ui/report/plugins/markdown-kit/callout-serializer.ts index e7c3b32d3..a87219cd2 100644 --- a/apps/web/src/components/ui/report/plugins/markdown-kit/callout-serializer.ts +++ b/apps/web/src/components/ui/report/plugins/markdown-kit/callout-serializer.ts @@ -1,6 +1,7 @@ import { type MdNodeParser, convertChildrenDeserialize, + deserializeMd, parseAttributes, serializeMd } from '@platejs/markdown'; @@ -21,20 +22,34 @@ export const calloutSerializer: MdNodeParser<'callout'> = { return { type: 'html', - value: `${content}` + value: `` }; }, deserialize: (node, deco, options) => { // Extract the icon attribute from the HTML element const typedAttributes = parseAttributes(node.attributes) as { icon: string; + content: string; }; - // Return the PlateJS node structure - return { - type: 'callout', - icon: typedAttributes.icon, - children: convertChildrenDeserialize(node.children, deco, options) - }; + if (!options.editor) { + throw new Error('Editor is required'); + } + + try { + const deserializedContent = deserializeMd(options.editor, typedAttributes.content); + return { + type: 'callout', + icon: typedAttributes.icon, + children: deserializedContent + }; + } catch (error) { + console.error('Error deserializing content', error); + return { + type: 'callout', + icon: typedAttributes.icon, + children: [{ text: typedAttributes.content }] + }; + } } }; diff --git a/apps/web/src/components/ui/report/plugins/markdown-kit/platejs-conversion.test.ts b/apps/web/src/components/ui/report/plugins/markdown-kit/platejs-conversion.test.ts index 5bfcc5e51..aa3524e55 100644 --- a/apps/web/src/components/ui/report/plugins/markdown-kit/platejs-conversion.test.ts +++ b/apps/web/src/components/ui/report/plugins/markdown-kit/platejs-conversion.test.ts @@ -259,6 +259,30 @@ Here's an unordered list: } ]); }); + + it('callout', async () => { + const markdown = ``; + const elements = await markdownToPlatejs(editor, markdown); + const firstElement = elements[0]; + expect(firstElement.type).toBe('callout'); + expect(firstElement.icon).toBe('💡'); + expect(firstElement.children[0]).toEqual({ type: 'p', children: [{ text: 'Testing123' }] }); + }); + + it('callout and a metric', async () => { + const markdown = ` + +Testing123 +`; + const elements = await markdownToPlatejs(editor, markdown); + expect(elements).toBeDefined(); + const firstElement = elements[0]; + expect(firstElement.type).toBe('metric'); + expect(firstElement.metricId).toBe('33af38a8-c40f-437d-98ed-1ec78ce35232'); + const secondElement = elements[1]; + + console.log(JSON.stringify(elements, null, 2)); + }); }); describe('platejsToMarkdown', () => { @@ -329,8 +353,9 @@ describe('platejsToMarkdown', () => { ]; const markdownFromPlatejs = await platejsToMarkdown(editor, elements); - const expectedMarkdown = `This is a simple paragraph.\n\n`; - expect(markdownFromPlatejs).toBe(expectedMarkdown); + const expectedMarkdown = ``; + expect(markdownFromPlatejs.trim()).toBe(expectedMarkdown.trim()); }); it('should convert callout platejs element to markdown', async () => { diff --git a/packages/ai/src/tools/visualization-tools/reports/modify-reports-tool/helpers/modify-reports-transform-helper.ts b/packages/ai/src/tools/visualization-tools/reports/modify-reports-tool/helpers/modify-reports-transform-helper.ts index 2bcea1b22..5e1841da4 100644 --- a/packages/ai/src/tools/visualization-tools/reports/modify-reports-tool/helpers/modify-reports-transform-helper.ts +++ b/packages/ai/src/tools/visualization-tools/reports/modify-reports-tool/helpers/modify-reports-transform-helper.ts @@ -68,10 +68,10 @@ export function createModifyReportsReasoningEntry( // Only show elapsed time when all edits are complete (not during streaming) // Check if all edits have a final status (completed or failed), not just 'loading' - const allEditsComplete = state.edits?.every( - (edit) => edit.status === 'completed' || edit.status === 'failed' - ) ?? false; - + const allEditsComplete = + state.edits?.every((edit) => edit.status === 'completed' || edit.status === 'failed') ?? + false; + if (allEditsComplete) { secondaryTitle = formatElapsedTime(state.startTime); } From 4277181a712c220186eb4885ffc4838f6723270f Mon Sep 17 00:00:00 2001 From: dal Date: Fri, 22 Aug 2025 08:44:48 -0600 Subject: [PATCH 9/9] Add ReportFile enum variant and implement related database schema and handlers --- apps/api/libs/database/src/enums.rs | 5 + apps/api/libs/database/src/models.rs | 21 +++ apps/api/libs/database/src/schema.rs | 25 ++++ .../handlers/src/favorites/favorites_utils.rs | 141 ++++++++++++++++-- .../ReportItemsContainer.tsx | 2 +- 5 files changed, 184 insertions(+), 10 deletions(-) diff --git a/apps/api/libs/database/src/enums.rs b/apps/api/libs/database/src/enums.rs index 30c42fb03..9b3e0dff2 100644 --- a/apps/api/libs/database/src/enums.rs +++ b/apps/api/libs/database/src/enums.rs @@ -233,6 +233,8 @@ pub enum AssetType { MetricFile, #[serde(rename = "dashboard")] DashboardFile, + #[serde(rename = "report")] + ReportFile, } impl AssetType { @@ -244,6 +246,7 @@ impl AssetType { AssetType::Chat => "chat", AssetType::MetricFile => "metric", AssetType::DashboardFile => "dashboard", + AssetType::ReportFile => "report", } } } @@ -591,6 +594,7 @@ impl ToSql for AssetType { AssetType::Chat => out.write_all(b"chat")?, AssetType::DashboardFile => out.write_all(b"dashboard_file")?, AssetType::MetricFile => out.write_all(b"metric_file")?, + AssetType::ReportFile => out.write_all(b"report_file")?, } Ok(IsNull::No) } @@ -605,6 +609,7 @@ impl FromSql for AssetType { b"chat" => Ok(AssetType::Chat), b"dashboard_file" => Ok(AssetType::DashboardFile), b"metric_file" => Ok(AssetType::MetricFile), + b"report_file" => Ok(AssetType::ReportFile), _ => Err("Unrecognized enum variant".into()), } } diff --git a/apps/api/libs/database/src/models.rs b/apps/api/libs/database/src/models.rs index f963839f4..3d3bd9384 100644 --- a/apps/api/libs/database/src/models.rs +++ b/apps/api/libs/database/src/models.rs @@ -105,6 +105,27 @@ pub struct MetricFile { pub workspace_sharing_enabled_at: Option>, } +#[derive(Queryable, Insertable, Identifiable, Debug, Clone, Serialize)] +#[diesel(table_name = report_files)] +pub struct ReportFile { + pub id: Uuid, + pub name: String, + pub content: String, + pub organization_id: Uuid, + pub created_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, + pub publicly_accessible: bool, + pub publicly_enabled_by: Option, + pub public_expiry_date: Option>, + pub version_history: VersionHistory, + pub public_password: Option, + pub workspace_sharing: WorkspaceSharing, + pub workspace_sharing_enabled_by: Option, + pub workspace_sharing_enabled_at: Option>, +} + #[derive(Queryable, Insertable, Identifiable, Associations, Debug, Clone, Serialize)] #[diesel(belongs_to(Organization))] #[diesel(belongs_to(User, foreign_key = created_by))] diff --git a/apps/api/libs/database/src/schema.rs b/apps/api/libs/database/src/schema.rs index 2bc6054f5..224ee9f46 100644 --- a/apps/api/libs/database/src/schema.rs +++ b/apps/api/libs/database/src/schema.rs @@ -463,6 +463,30 @@ diesel::table! { } } +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::WorkspaceSharingEnum; + + report_files (id) { + id -> Uuid, + name -> Varchar, + content -> Text, + organization_id -> Uuid, + created_by -> Uuid, + created_at -> Timestamptz, + updated_at -> Timestamptz, + deleted_at -> Nullable, + publicly_accessible -> Bool, + publicly_enabled_by -> Nullable, + public_expiry_date -> Nullable, + version_history -> Jsonb, + public_password -> Nullable, + workspace_sharing -> WorkspaceSharingEnum, + workspace_sharing_enabled_by -> Nullable, + workspace_sharing_enabled_at -> Nullable, + } +} + diesel::table! { use diesel::sql_types::*; use super::sql_types::UserOrganizationRoleEnum; @@ -757,6 +781,7 @@ diesel::allow_tables_to_appear_in_same_query!( metric_files_to_dashboard_files, metric_files_to_datasets, organizations, + report_files, permission_groups, permission_groups_to_identities, permission_groups_to_users, diff --git a/apps/api/libs/handlers/src/favorites/favorites_utils.rs b/apps/api/libs/handlers/src/favorites/favorites_utils.rs index 109191b5c..1806ef3eb 100644 --- a/apps/api/libs/handlers/src/favorites/favorites_utils.rs +++ b/apps/api/libs/handlers/src/favorites/favorites_utils.rs @@ -10,7 +10,7 @@ use database::{ enums::AssetType, pool::get_pg_pool, models::UserFavorite, - schema::{collections, collections_to_assets, dashboard_files, chats, messages_deprecated, threads_deprecated, user_favorites, metric_files}, + schema::{collections, collections_to_assets, dashboard_files, chats, messages_deprecated, threads_deprecated, user_favorites, metric_files, report_files}, }; use middleware::AuthenticatedUser; @@ -108,10 +108,21 @@ pub async fn list_user_favorites(user: &AuthenticatedUser) -> Result { - (dashboard_fav_res, collection_fav_res, threads_fav_res, metrics_fav_res, chats_fav_res) + let reports_favorites = { + let report_ids = Arc::new( + user_favorites + .iter() + .filter(|(_, f)| f == &AssetType::ReportFile) + .map(|f| f.0) + .collect::>(), + ); + tokio::spawn(async move { get_favorite_reports(report_ids).await }) + }; + + let (dashboard_fav_res, collection_fav_res, threads_fav_res, metrics_fav_res, chats_fav_res, reports_fav_res) = + match tokio::try_join!(dashboard_favorites, collection_favorites, threads_favorites, metrics_favorites, chats_favorites, reports_favorites) { + Ok((dashboard_fav_res, collection_fav_res, threads_fav_res, metrics_fav_res, chats_fav_res, reports_fav_res)) => { + (dashboard_fav_res, collection_fav_res, threads_fav_res, metrics_fav_res, chats_fav_res, reports_fav_res) } Err(e) => { tracing::error!("Error getting favorite assets: {}", e); @@ -159,6 +170,14 @@ pub async fn list_user_favorites(user: &AuthenticatedUser) -> Result reports, + Err(e) => { + tracing::error!("Error getting favorite reports: {}", e); + return Err(anyhow!("Error getting favorite reports: {}", e)); + } + }; + let mut favorites: Vec = Vec::with_capacity(user_favorites.len()); for favorite in &user_favorites { @@ -211,6 +230,15 @@ pub async fn list_user_favorites(user: &AuthenticatedUser) -> Result { + if let Some(report) = favorite_reports.iter().find(|r| r.id == favorite.0) { + favorites.push(FavoriteObject { + id: report.id, + name: report.name.clone(), + type_: AssetType::ReportFile, + }); + } + } _ => {} } } @@ -329,15 +357,20 @@ async fn get_assets_from_collections( tokio::spawn(async move { get_chats_from_collections(&collection_ids).await }) }; + let reports_handle = { + let collection_ids = Arc::clone(&collection_ids); + tokio::spawn(async move { get_reports_from_collections(&collection_ids).await }) + }; + let collection_name_handle = { let collection_ids = Arc::clone(&collection_ids); tokio::spawn(async move { get_collection_names(&collection_ids).await }) }; - let (dashboards_res, metrics_res, chats_res, collection_name_res) = - match tokio::join!(dashboards_handle, metrics_handle, chats_handle, collection_name_handle) { - (Ok(dashboards), Ok(metrics), Ok(chats), Ok(collection_name)) => { - (dashboards, metrics, chats, collection_name) + let (dashboards_res, metrics_res, chats_res, reports_res, collection_name_res) = + match tokio::join!(dashboards_handle, metrics_handle, chats_handle, reports_handle, collection_name_handle) { + (Ok(dashboards), Ok(metrics), Ok(chats), Ok(reports), Ok(collection_name)) => { + (dashboards, metrics, chats, reports, collection_name) } _ => { return Err(anyhow!( @@ -361,6 +394,11 @@ async fn get_assets_from_collections( Err(e) => return Err(anyhow!("Error getting chats from collection: {:?}", e)), }; + let reports = match reports_res { + Ok(reports) => reports, + Err(e) => return Err(anyhow!("Error getting reports from collection: {:?}", e)), + }; + let collection_names = match collection_name_res { Ok(collection_names) => collection_names, Err(e) => return Err(anyhow!("Error getting collection name: {:?}", e)), @@ -407,6 +445,18 @@ async fn get_assets_from_collections( }), ); + assets.extend( + reports + .iter() + .filter_map(|(report_collection_id, favorite_object)| { + if *report_collection_id == collection_id { + Some(favorite_object.clone()) + } else { + None + } + }), + ); + collection_favorites.push(FavoriteObject { id: collection_id, name: collection_name, @@ -568,6 +618,50 @@ async fn get_chats_from_collections( Ok(chat_objects) } +async fn get_reports_from_collections( + collection_ids: &[Uuid], +) -> Result> { + let mut conn = match get_pg_pool().get().await { + Ok(conn) => conn, + Err(e) => return Err(anyhow!("Error getting connection from pool: {:?}", e)), + }; + + let report_records: Vec<(Uuid, Uuid, String)> = match report_files::table + .inner_join( + collections_to_assets::table.on(report_files::id.eq(collections_to_assets::asset_id)), + ) + .select(( + collections_to_assets::collection_id, + report_files::id, + report_files::name, + )) + .filter(collections_to_assets::collection_id.eq_any(collection_ids)) + .filter(collections_to_assets::asset_type.eq(AssetType::ReportFile)) + .filter(report_files::deleted_at.is_null()) + .filter(collections_to_assets::deleted_at.is_null()) + .load::<(Uuid, Uuid, String)>(&mut conn) + .await + { + Ok(report_records) => report_records, + Err(e) => return Err(anyhow!("Error loading report records: {:?}", e)), + }; + + let report_objects: Vec<(Uuid, FavoriteObject)> = report_records + .iter() + .map(|(collection_id, id, name)| { + ( + *collection_id, + FavoriteObject { + id: *id, + name: name.clone(), + type_: AssetType::ReportFile, + }, + ) + }) + .collect(); + Ok(report_objects) +} + async fn get_favorite_metrics(metric_ids: Arc>) -> Result> { let mut conn = match get_pg_pool().get().await { Ok(conn) => conn, @@ -597,6 +691,35 @@ async fn get_favorite_metrics(metric_ids: Arc>) -> Result>) -> Result> { + let mut conn = match get_pg_pool().get().await { + Ok(conn) => conn, + Err(e) => return Err(anyhow!("Error getting connection from pool: {:?}", e)), + }; + + let report_records: Vec<(Uuid, String)> = match report_files::table + .select((report_files::id, report_files::name)) + .filter(report_files::id.eq_any(report_ids.as_ref())) + .filter(report_files::deleted_at.is_null()) + .load::<(Uuid, String)>(&mut conn) + .await + { + Ok(report_records) => report_records, + Err(diesel::NotFound) => return Err(anyhow!("Reports not found")), + Err(e) => return Err(anyhow!("Error loading report records: {:?}", e)), + }; + + let favorite_reports = report_records + .iter() + .map(|(id, name)| FavoriteObject { + id: *id, + name: name.clone(), + type_: AssetType::ReportFile, + }) + .collect(); + Ok(favorite_reports) +} + pub async fn update_favorites(user: &AuthenticatedUser, favorites: &[Uuid]) -> Result<()> { let mut conn = match get_pg_pool().get().await { Ok(conn) => conn, diff --git a/apps/web/src/controllers/ReportsListController/ReportItemsContainer.tsx b/apps/web/src/controllers/ReportsListController/ReportItemsContainer.tsx index 4feff10d5..75c9b7a48 100644 --- a/apps/web/src/controllers/ReportsListController/ReportItemsContainer.tsx +++ b/apps/web/src/controllers/ReportsListController/ReportItemsContainer.tsx @@ -155,7 +155,7 @@ const TitleCell = React.memo<{ name: string; chatId: string }>(({ name, chatId }