merge staging

This commit is contained in:
dal 2025-08-22 09:00:26 -06:00
commit 50e8e431b5
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
20 changed files with 311 additions and 287 deletions

View File

@ -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<sql_types::AssetTypeEnum, Pg> 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<sql_types::AssetTypeEnum, Pg> 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()),
}
}

View File

@ -105,6 +105,27 @@ pub struct MetricFile {
pub workspace_sharing_enabled_at: Option<DateTime<Utc>>,
}
#[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<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
pub publicly_accessible: bool,
pub publicly_enabled_by: Option<Uuid>,
pub public_expiry_date: Option<DateTime<Utc>>,
pub version_history: VersionHistory,
pub public_password: Option<String>,
pub workspace_sharing: WorkspaceSharing,
pub workspace_sharing_enabled_by: Option<Uuid>,
pub workspace_sharing_enabled_at: Option<DateTime<Utc>>,
}
#[derive(Queryable, Insertable, Identifiable, Associations, Debug, Clone, Serialize)]
#[diesel(belongs_to(Organization))]
#[diesel(belongs_to(User, foreign_key = created_by))]

View File

@ -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<Timestamptz>,
publicly_accessible -> Bool,
publicly_enabled_by -> Nullable<Uuid>,
public_expiry_date -> Nullable<Timestamptz>,
version_history -> Jsonb,
public_password -> Nullable<Text>,
workspace_sharing -> WorkspaceSharingEnum,
workspace_sharing_enabled_by -> Nullable<Uuid>,
workspace_sharing_enabled_at -> Nullable<Timestamptz>,
}
}
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,

View File

@ -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<Vec<Favorit
tokio::spawn(async move { get_favorite_chats(chat_ids).await })
};
let (dashboard_fav_res, collection_fav_res, threads_fav_res, metrics_fav_res, chats_fav_res) =
match tokio::try_join!(dashboard_favorites, collection_favorites, threads_favorites, metrics_favorites, chats_favorites) {
Ok((dashboard_fav_res, collection_fav_res, threads_fav_res, metrics_fav_res, chats_fav_res)) => {
(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::<Vec<Uuid>>(),
);
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<Vec<Favorit
}
};
let favorite_reports = match reports_fav_res {
Ok(reports) => reports,
Err(e) => {
tracing::error!("Error getting favorite reports: {}", e);
return Err(anyhow!("Error getting favorite reports: {}", e));
}
};
let mut favorites: Vec<FavoriteObject> = Vec::with_capacity(user_favorites.len());
for favorite in &user_favorites {
@ -211,6 +230,15 @@ pub async fn list_user_favorites(user: &AuthenticatedUser) -> Result<Vec<Favorit
});
}
}
AssetType::ReportFile => {
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<Vec<(Uuid, FavoriteObject)>> {
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<Vec<Uuid>>) -> Result<Vec<FavoriteObject>> {
let mut conn = match get_pg_pool().get().await {
Ok(conn) => conn,
@ -597,6 +691,35 @@ async fn get_favorite_metrics(metric_ids: Arc<Vec<Uuid>>) -> Result<Vec<Favorite
Ok(favorite_metrics)
}
async fn get_favorite_reports(report_ids: Arc<Vec<Uuid>>) -> Result<Vec<FavoriteObject>> {
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,

View File

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

View File

@ -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 (
<div className="h-[700px] overflow-hidden border-2 border-blue-700">
<DynamicReportEditor
value={content}
placeholder="Start typing..."
// disabled={false}
className={commonClassName}
variant="default"
useFixedToolbarKit={false}
onValueChange={onChangeContent}
readOnly={readOnly}
mode={mode}
onReady={onReadyProp}
isStreaming={isStreamingMessage}
/>
</div>
);
}
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();

View File

@ -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 (
<div
data-testid="reasoning-scroll-to-bottom"
data-testid="scroll-to-bottom-button"
className={cn(
'absolute right-4 bottom-4 z-10 duration-300',
isAutoScrollEnabled
? 'pointer-events-none scale-90 opacity-0'
: 'pointer-events-auto scale-100 cursor-pointer opacity-100'
: 'pointer-events-auto scale-100 cursor-pointer opacity-100',
className
)}>
<AppTooltip title="Stick to bottom" sideOffset={12} delayDuration={500}>
<button
@ -30,4 +32,4 @@ export const ReasoningScrollToBottom: React.FC<{
);
});
ReasoningScrollToBottom.displayName = 'ReasoningScrollToBottom';
ScrollToBottomButton.displayName = 'ScrollToBottomButton';

View File

@ -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;
@ -9,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: {
@ -37,22 +38,24 @@ const editorContainerVariants = cva(
}
);
export function EditorContainer({
className,
variant,
readOnly,
...props
}: React.ComponentProps<'div'> &
VariantProps<typeof editorContainerVariants> &
EditorContainerProps) {
export const EditorContainer = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof editorContainerVariants> &
EditorContainerProps
>(({ className, variant, readOnly, children, ...htmlProps }, ref) => {
return (
<PlateContainer
<div
ref={ref}
className={cn(
'ignore-click-outside/toolbar',
editorContainerVariants({ variant, readOnly }),
className
)}
{...props}
/>
{...htmlProps}>
<PlateContainer>{children}</PlateContainer>
</div>
);
}
});
EditorContainer.displayName = 'EditorContainer';

View File

@ -56,7 +56,6 @@ const meta = {
args: {
placeholder: 'Start typing...',
readOnly: false,
disabled: false,
variant: 'default'
}
} satisfies Meta<typeof ReportEditor>;

View File

@ -2,8 +2,9 @@
import type { Value, AnyPluginConfig } from 'platejs';
import { Plate, type TPlateEditor } from 'platejs/react';
import React, { useImperativeHandle, useRef } from 'react';
import React, { useEffect, 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';
@ -11,7 +12,7 @@ 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 {
// We accept the generic Value type but recommend using ReportTypes.Value for type safety
@ -29,7 +30,7 @@ interface ReportEditorProps {
onReady?: (editor: IReportEditor) => void;
id?: string;
mode?: 'export' | 'default';
children?: React.ReactNode;
preEditorChildren?: React.ReactNode;
postEditorChildren?: React.ReactNode;
}
@ -59,13 +60,21 @@ export const ReportEditor = React.memo(
useFixedToolbarKit = false,
readOnly = false,
isStreaming = false,
children,
preEditorChildren,
postEditorChildren
},
ref
) => {
// Initialize the editor instance using the custom useEditor hook
const isReady = useRef(false);
const editorContainerRef = useRef<HTMLDivElement>(null);
const { isAutoScrollEnabled, enableAutoScroll, disableAutoScroll, scrollToBottom } =
useAutoScroll(editorContainerRef, {
enabled: isStreaming,
bottomThreshold: 50,
observeSubTree: true
});
const editor = useReportEditor({
isStreaming,
@ -118,15 +127,24 @@ export const ReportEditor = React.memo(
wait: 1500
});
useEffect(() => {
if (isStreaming) {
enableAutoScroll();
} else {
disableAutoScroll();
}
}, [isStreaming]);
if (!editor) return null;
return (
<Plate editor={editor} onValueChange={onValueChangeDebounced}>
<EditorContainer
ref={editorContainerRef}
variant={variant}
readOnly={readOnly}
className={cn('editor-container relative overflow-auto', containerClassName)}>
{children}
{preEditorChildren}
<ThemeWrapper id={id}>
<Editor
style={style}
@ -137,6 +155,13 @@ export const ReportEditor = React.memo(
/>
</ThemeWrapper>
{postEditorChildren}
{isStreaming && (
<ScrollToBottomButton
isAutoScrollEnabled={isAutoScrollEnabled}
scrollToBottom={scrollToBottom}
className="fixed right-8 bottom-8 z-10"
/>
)}
</EditorContainer>
</Plate>
);

View File

@ -18,7 +18,7 @@ export function HrElement(props: PlateElementProps) {
<div className="py-6" contentEditable={false}>
<hr
className={cn(
'bg-muted h-0.5 rounded-sm border-none bg-clip-content',
'border-border border-0 border-t',
selected && focused && 'ring-ring ring-2 ring-offset-2',
!readOnly && 'cursor-pointer'
)}

View File

@ -10,7 +10,7 @@ export function HrElementStatic(props: SlateElementProps) {
return (
<SlateElement {...props}>
<div className="cursor-text py-6" contentEditable={false}>
<hr className={cn('bg-muted h-0.5 rounded-sm border-none bg-clip-content')} />
<hr className={cn('border-border border-0 border-t')} />
</div>
{props.children}
</SlateElement>

View File

@ -25,11 +25,15 @@ export const MetricContent = React.memo(
const reportId = useChatLayoutContextSelector((x) => x.reportId) || '';
const reportVersionNumber = useChatLayoutContextSelector((x) => x.reportVersionNumber);
const ref = useRef<HTMLDivElement>(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,

View File

@ -15,7 +15,7 @@ export function StreamingText(props: PlateTextProps) {
<PlateText
className={cn(
'streaming-node',
isStreaming && ['animate-highlight-fade'],
isStreaming && 'animate-highlight-fade',
// Only show the animated dot on the last streaming text node
isLastStreamingText && [
'after:ml-1.5 after:inline-block after:h-3 after:w-3 after:animate-pulse after:rounded-full after:bg-purple-500 after:align-middle after:content-[""]'

View File

@ -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: `<callout icon="${icon}">${content}</callout>`
value: `<callout icon="${icon}" content="${content}"></callout>`
};
},
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 }]
};
}
}
};

View File

@ -259,6 +259,30 @@ Here's an unordered list:
}
]);
});
it('callout', async () => {
const markdown = `<callout icon="💡" content="Testing123"></callout>`;
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 = `<metric metricId="33af38a8-c40f-437d-98ed-1ec78ce35232" width="100%" caption=""></metric>
<callout icon="💡">Testing123
</callout>`;
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 = `<callout icon="⚠️">This is a simple paragraph.\n</callout>\n`;
expect(markdownFromPlatejs).toBe(expectedMarkdown);
const expectedMarkdown = `<callout icon="⚠️" content="This is a simple paragraph.
"></callout>`;
expect(markdownFromPlatejs.trim()).toBe(expectedMarkdown.trim());
});
it('should convert callout platejs element to markdown', async () => {

View File

@ -11,7 +11,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { useAutoScroll } from '@/hooks/useAutoScroll';
import { ReasoningMessageSelector } from './ReasoningMessages';
import { BlackBoxMessage } from './ReasoningMessages/ReasoningBlackBoxMessage';
import { ReasoningScrollToBottom } from './ReasoningScrollToBottom';
import { ScrollToBottomButton } from '@/components/ui/buttons/ScrollToBottomButton';
import type { BusterChatMessage, IBusterChat } from '@/api/asset_interfaces/chat';
interface ReasoningControllerProps {
@ -79,7 +79,7 @@ export const ReasoningController: React.FC<ReasoningControllerProps> = ({ chatId
</div>
</ScrollArea>
<ReasoningScrollToBottom
<ScrollToBottomButton
isAutoScrollEnabled={isAutoScrollEnabled}
scrollToBottom={scrollToBottom}
/>

View File

@ -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;
@ -73,19 +71,20 @@ export const ReportPageController: React.FC<{
mode={mode}
onReady={onReadyProp}
isStreaming={isStreamingMessage}
preEditorChildren={
<ReportPageHeader
name={report?.name}
updatedAt={report?.updated_at}
onChangeName={onChangeName}
className={commonClassName}
isStreaming={isStreamingMessage}
/>
}
postEditorChildren={
showGeneratingContent ? (
<GeneratingContent messageId={messageId} className={commonClassName} />
) : null
}>
<ReportPageHeader
name={report?.name}
updatedAt={report?.updated_at}
onChangeName={onChangeName}
className={commonClassName}
isStreaming={isStreamingMessage}
/>
</DynamicReportEditor>
}></DynamicReportEditor>
) : (
<ReportEditorSkeleton />
)}

View File

@ -155,7 +155,7 @@ const TitleCell = React.memo<{ name: string; chatId: string }>(({ name, chatId }
<div className="mr-2 flex items-center" onClick={onFavoriteDivClick}>
<FavoriteStar
id={chatId}
type={'chat'}
type={'report'}
iconStyle="tertiary"
title={name}
className="opacity-0 group-hover:opacity-100"

View File

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