Skip to content

Commit 6360f9e

Browse files
committed
Merge remote-tracking branch 'origin/staging' into codex/remove-indexed-enterprise-search
2 parents b872060 + b6a598e commit 6360f9e

31 files changed

Lines changed: 1626 additions & 330 deletions

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx‎

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react'
1+
import { lazy, Suspense, useContext, useEffect, useState } from 'react'
22
import {
33
chipVariants,
44
cn,
@@ -11,11 +11,18 @@ import {
1111
import { Check, ChevronDown, Code, Duplicate, Eye, Wrap } from '@sim/emcn/icons'
1212
import type { ReactNodeViewProps } from '@tiptap/react'
1313
import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
14+
import { DASHBOARD_EMBED_LANGUAGE } from '@/lib/dashboards/embed-language'
15+
import { MarkdownStreamingContext } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-streaming-context'
1416
import { looksLikeMermaid, MermaidDiagram } from '../mermaid-diagram'
1517
import { MarkdownCodeBlock } from './code-block-schema'
1618
import { detectLanguage } from './detect-language'
1719
import { useEditorEditable } from './use-editor-editable'
1820

21+
/** Kept out of every rich-markdown surface's graph until a document actually holds a dashboard. */
22+
const DashboardEmbed = lazy(() =>
23+
import('@/components/dashboards/dashboard-embed').then((m) => ({ default: m.DashboardEmbed }))
24+
)
25+
1926
const PLAIN = 'plain'
2027
const MERMAID = 'mermaid'
2128

@@ -51,7 +58,8 @@ const CONTROL_CLASS =
5158
* whenever the cursor is outside it (and always in read-only), and as editable source while the
5259
* cursor is inside, re-rendering on blur (the Linear/GitHub model). The source `<pre>` stays mounted
5360
* (hidden behind the diagram) so ProseMirror keeps managing its contentDOM, and the node remains an
54-
* ordinary code block, so markdown round-trips unchanged.
61+
* ordinary code block, so markdown round-trips unchanged. A ```dashboard fence renders live
62+
* dashboard panels the same way.
5563
*/
5664
function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeViewProps) {
5765
const [wrap, setWrap] = useState(false)
@@ -60,16 +68,19 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
6068
const [peekSource, setPeekSource] = useState(false)
6169
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
6270
const editable = useEditorEditable(editor)
71+
const isStreaming = useContext(MarkdownStreamingContext)
6372

6473
const explicitLanguage = node.attrs.language as string | null
6574
const text = node.textContent
6675
const isMermaid = explicitLanguage === MERMAID || (!explicitLanguage && looksLikeMermaid(text))
76+
const isDashboard = explicitLanguage === DASHBOARD_EMBED_LANGUAGE
77+
const isRendered = isMermaid || isDashboard
6778

6879
// Editable Mermaid shows source while the caret is focused inside the block and re-renders the
6980
// diagram on blur (the Linear/GitHub model). The Show source / Show diagram control drives this by
7081
// focusing into / blurring the block; read-only uses {@link peekSource} since there is no caret.
7182
useEffect(() => {
72-
if (!isMermaid || !editable) {
83+
if (!isRendered || !editable) {
7384
setEditingInline(false)
7485
return
7586
}
@@ -92,13 +103,13 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
92103
editor.off('focus', sync)
93104
editor.off('blur', sync)
94105
}
95-
}, [editor, getPos, isMermaid, editable])
106+
}, [editor, getPos, isRendered, editable])
96107

97108
const showSource = editable ? editingInline : peekSource
98-
const showDiagram = isMermaid && text.trim().length > 0 && !showSource
109+
const showRendered = isRendered && text.trim().length > 0 && !showSource
99110

100-
// Skip language detection on the mermaid path — the picker/label never render there.
101-
const language = explicitLanguage ?? (isMermaid ? null : detectLanguage(text)) ?? PLAIN
111+
// Skip language detection on rendered blocks — the picker/label never render there.
112+
const language = explicitLanguage ?? (isRendered ? null : detectLanguage(text)) ?? PLAIN
102113
const label =
103114
LANGUAGE_OPTIONS.find((option) => option.value === language)?.label ??
104115
explicitLanguage ??
@@ -133,18 +144,20 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
133144
)}
134145
contentEditable={false}
135146
>
136-
{isMermaid && (
147+
{isRendered && (
137148
<button
138149
type='button'
139-
aria-label={showSource ? 'Show diagram' : 'Show source'}
150+
aria-label={
151+
showSource ? (isDashboard ? 'Show dashboard' : 'Show diagram') : 'Show source'
152+
}
140153
onMouseDown={(event) => event.preventDefault()}
141154
onClick={toggleSource}
142155
className={CONTROL_CLASS}
143156
>
144157
{showSource ? <Eye /> : <Code />}
145158
</button>
146159
)}
147-
{!isMermaid &&
160+
{!isRendered &&
148161
(editable ? (
149162
// Editable: a language picker. Read-only: a static label — selecting a language calls
150163
// updateAttributes, which would mutate a doc that must not change.
@@ -180,7 +193,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
180193
{label}
181194
</span>
182195
))}
183-
{!isMermaid && editable && (
196+
{!isRendered && editable && (
184197
<button
185198
type='button'
186199
aria-label='Toggle line wrap'
@@ -205,22 +218,30 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
205218
{copied ? <Check /> : <Duplicate />}
206219
</button>
207220
</div>
208-
<pre className={cn('code-editor-theme pr-20', showDiagram && 'hidden')} data-wrap={wrap}>
221+
<pre className={cn('code-editor-theme pr-20', showRendered && 'hidden')} data-wrap={wrap}>
209222
<NodeViewContent<'code'> as='code' />
210223
</pre>
211-
{showDiagram && (
212-
// Clicking the diagram selects the whole node (same selection ring as an image/code block)
213-
// instead of dropping a caret inside — preventDefault stops ProseMirror placing the caret,
214-
// which would otherwise flip to source. Editing is an explicit Show source / blur action.
224+
{showRendered && (
225+
// Select the whole node instead of placing a caret, which would flip the block to source.
215226
<div
216227
contentEditable={false}
228+
className={cn(isDashboard && 'dashboard-embed')}
217229
onMouseDown={(event) => {
230+
const target = event.target
231+
if (!(target instanceof Element) || !event.currentTarget.contains(target)) return
232+
if (target.closest('button, input')) return
218233
event.preventDefault()
219234
const pos = typeof getPos === 'function' ? getPos() : null
220235
if (typeof pos === 'number') editor.commands.setNodeSelection(pos)
221236
}}
222237
>
223-
<MermaidDiagram definition={text} className='mermaid-diagram-frame' />
238+
{isDashboard ? (
239+
<Suspense fallback={null}>
240+
<DashboardEmbed source={text} isStreaming={isStreaming} />
241+
</Suspense>
242+
) : (
243+
<MermaidDiagram definition={text} className='mermaid-diagram-frame' />
244+
)}
224245
</div>
225246
)}
226247
</NodeViewWrapper>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { createContext } from 'react'
2+
3+
/** True while agent output is streaming into the editor, for node views that render its content. */
4+
export const MarkdownStreamingContext = createContext(false)

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,12 @@
391391
margin: 1rem 0;
392392
}
393393

394+
/* A dashboard results table sizes its own columns and sits flush in its panel. */
395+
.rich-markdown-nodes .dashboard-embed table {
396+
table-layout: auto;
397+
margin: 0;
398+
}
399+
394400
.rich-markdown-nodes th > p,
395401
.rich-markdown-nodes td > p {
396402
margin: 0;

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity'
6969
import { parseMarkdownToDoc } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse'
7070
import { isPlainTextPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste'
71+
import { MarkdownStreamingContext } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-streaming-context'
7172
import { useEditorMentions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention'
7273
import { EditorBubbleMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu'
7374
import { LinkHoverCard } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card'
@@ -1439,17 +1440,19 @@ export function LoadedRichMarkdownEditor({
14391440
void insertImagesRef.current(images, range)
14401441
}}
14411442
/>
1442-
{showPlaceholder && placeholder && (
1443-
<ReadOnlyPlaceholder
1444-
content={placeholder.content}
1445-
file={file}
1446-
workspaceId={workspaceId}
1443+
<MarkdownStreamingContext value={isStreaming}>
1444+
{showPlaceholder && placeholder && (
1445+
<ReadOnlyPlaceholder
1446+
content={placeholder.content}
1447+
file={file}
1448+
workspaceId={workspaceId}
1449+
/>
1450+
)}
1451+
<EditorContent
1452+
editor={editor}
1453+
className={cn(EDITOR_SURFACE_CLASS, showPlaceholder && 'hidden')}
14471454
/>
1448-
)}
1449-
<EditorContent
1450-
editor={editor}
1451-
className={cn(EDITOR_SURFACE_CLASS, showPlaceholder && 'hidden')}
1452-
/>
1455+
</MarkdownStreamingContext>
14531456
</div>
14541457
</div>
14551458
)

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity'
2828
import { parseMarkdownToDoc } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse'
2929
import { isPlainTextPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste'
30+
import { MarkdownStreamingContext } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-streaming-context'
3031
import { useEditorMentions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention'
3132
import { EditorBubbleMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu'
3233
import { LinkHoverCard } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card'
@@ -444,15 +445,17 @@ function LoadedRichMarkdownField({
444445
}}
445446
/>
446447
)}
447-
<EditorContent
448-
editor={editor}
449-
className={cn(
450-
'flex flex-1 flex-col',
451-
isBare
452-
? proseClassName
453-
: 'selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white'
454-
)}
455-
/>
448+
<MarkdownStreamingContext value={isStreaming}>
449+
<EditorContent
450+
editor={editor}
451+
className={cn(
452+
'flex flex-1 flex-col',
453+
isBare
454+
? proseClassName
455+
: 'selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white'
456+
)}
457+
/>
458+
</MarkdownStreamingContext>
456459
</div>
457460
)
458461
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { Editor } from '@tiptap/core'
5+
import { describe, expect, it } from 'vitest'
6+
import { createMarkdownEditorExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions'
7+
import { SLASH_COMMANDS } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands'
8+
9+
const chart = SLASH_COMMANDS.find((command) => command.title === 'Chart')
10+
11+
describe('Chart slash command', () => {
12+
it.each([
13+
['an empty paragraph', '<p>/chart</p>'],
14+
['the end of existing text', '<p>Intro /chart</p>'],
15+
])('puts the caret at the start of the starter fence from %s', (_, content) => {
16+
const editor = new Editor({
17+
extensions: createMarkdownEditorExtensions({ placeholder: '' }),
18+
content,
19+
})
20+
const end = editor.state.doc.content.size - 1
21+
const from = editor.state.doc.textBetween(0, end).indexOf('/') + 1
22+
chart?.run({ editor, range: { from, to: end } })
23+
const { $from } = editor.state.selection
24+
expect($from.parent.type.name).toBe('codeBlock')
25+
expect($from.parent.attrs.language).toBe('dashboard')
26+
expect($from.parentOffset).toBe(0)
27+
editor.destroy()
28+
})
29+
})

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ComponentType, SVGProps } from 'react'
22
import {
3+
ChartColumn,
34
Code,
45
Heading1,
56
Heading2,
@@ -14,6 +15,27 @@ import {
1415
TextQuote,
1516
} from '@sim/emcn/icons'
1617
import type { Editor, Range } from '@tiptap/core'
18+
import { TextSelection } from '@tiptap/pm/state'
19+
import { DASHBOARD_EMBED_LANGUAGE } from '@/lib/dashboards/embed-language'
20+
21+
/** A time-series starter; the table id is left for the author to fill in. */
22+
const DASHBOARD_EMBED_STARTER = `title: Rows over time
23+
time: 7d
24+
source:
25+
tableId: # table id
26+
blocks:
27+
- chart: Rows per day
28+
source:
29+
groupBy: [createdAt]
30+
bucket: day
31+
aggregate:
32+
rows: { op: count }
33+
option:
34+
xAxis: { type: time }
35+
yAxis: { type: value }
36+
series:
37+
- { type: line, encode: { x: createdAt, y: rows } }
38+
`
1739

1840
export interface SlashCommandContext {
1941
editor: Editor
@@ -121,6 +143,33 @@ export const SLASH_COMMANDS: readonly SlashCommandItem[] = [
121143
shortcut: '⌘⌥C',
122144
run: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
123145
},
146+
{
147+
title: 'Chart',
148+
group: 'Blocks',
149+
icon: ChartColumn,
150+
aliases: ['dashboard', 'graph', 'metric', 'live data'],
151+
run: ({ editor, range }) =>
152+
editor
153+
.chain()
154+
.focus()
155+
.deleteRange(range)
156+
.insertContent({
157+
type: 'codeBlock',
158+
attrs: { language: DASHBOARD_EMBED_LANGUAGE },
159+
content: [{ type: 'text', text: DASHBOARD_EMBED_STARTER }],
160+
})
161+
.command(({ tr }) => {
162+
let fence = -1
163+
tr.doc.nodesBetween(range.from - 1, tr.doc.content.size, (node, pos) => {
164+
if (fence < 0 && node.type.name === 'codeBlock') fence = pos
165+
return fence < 0
166+
})
167+
if (fence < 0) return false
168+
tr.setSelection(TextSelection.create(tr.doc, fence + 1))
169+
return true
170+
})
171+
.run(),
172+
},
124173
{
125174
title: 'Table',
126175
group: 'Blocks',

‎apps/sim/components/charts/echarts-view.tsx‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,23 @@ import { cn } from '@sim/emcn'
55
import { getErrorMessage } from '@sim/utils/errors'
66
import type { EChartsType } from 'echarts'
77
import { useTheme } from 'next-themes'
8+
import { applyChartAnnotations, type ChartAnnotations } from '@/lib/charts/annotations'
89
import { installBarRowHighlight } from '@/lib/charts/bar-row-highlight'
910
import { chartSummaryExtension } from '@/lib/charts/summary'
10-
import { applyChartTooltipDefaults, readEmcnChartTheme } from '@/lib/charts/theme'
11+
import {
12+
applyChartTooltipDefaults,
13+
readChartTonePalette,
14+
readEmcnChartTheme,
15+
} from '@/lib/charts/theme'
1116

1217
interface EChartsViewProps {
1318
option: Record<string, unknown>
1419
label: string
1520
className?: string
1621
createController?: (chart: EChartsType) => EChartsController
1722
revision?: string
23+
/** Highlights and thresholds, coloured from the theme at render time. */
24+
annotations?: ChartAnnotations
1825
}
1926

2027
export interface EChartsController {
@@ -33,22 +40,31 @@ export function EChartsView({
3340
className,
3441
createController,
3542
revision,
43+
annotations,
3644
}: EChartsViewProps) {
3745
const containerRef = useRef<HTMLDivElement>(null)
3846
const chartRef = useRef<EChartsType | null>(null)
3947
const controllerRef = useRef<EChartsController | null>(null)
4048
const rowHighlightRef = useRef<(() => void) | null>(null)
4149
const { resolvedTheme } = useTheme()
4250
const [status, setStatus] = useState<{ theme: string | undefined; error?: string } | null>(null)
43-
const optionKey = JSON.stringify(option)
51+
const optionKey = JSON.stringify({ option, annotations })
4452
const applyOption = useEffectEvent((chart: EChartsType, nextOption: string) => {
4553
try {
4654
controllerRef.current?.dispose()
4755
controllerRef.current = null
4856
const controller = createController?.(chart)
4957
controllerRef.current = controller ?? null
50-
const parsed = applyChartTooltipDefaults(JSON.parse(nextOption))
51-
chart.setOption(controller ? controller.prepareOption(parsed) : parsed, { notMerge: true })
58+
const next: { option: Record<string, unknown>; annotations?: ChartAnnotations } =
59+
JSON.parse(nextOption)
60+
const parsed = applyChartTooltipDefaults(next.option)
61+
// Annotate after the bar helpers read the option: the label series would read as a non-bar chart.
62+
const rendered = next.annotations
63+
? applyChartAnnotations(parsed, next.annotations, readChartTonePalette(chart.getDom()))
64+
: parsed
65+
chart.setOption(controller ? controller.prepareOption(rendered) : rendered, {
66+
notMerge: true,
67+
})
5268
controller?.afterUpdate()
5369
rowHighlightRef.current?.()
5470
rowHighlightRef.current = installBarRowHighlight(chart, parsed)

0 commit comments

Comments
 (0)