Skip to content

Commit 6d54f88

Browse files
feat(dashboards): embed live dashboard panels in markdown
1 parent 60a1f6f commit 6d54f88

21 files changed

Lines changed: 1117 additions & 238 deletions

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

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react'
1+
import { useContext, useEffect, useState } from 'react'
22
import {
33
chipVariants,
44
cn,
@@ -11,6 +11,9 @@ 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 { DashboardEmbed } from '@/components/dashboards/dashboard-embed'
15+
import { DASHBOARD_EMBED_LANGUAGE } from '@/lib/dashboards/spec'
16+
import { MarkdownStreamingContext } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-streaming-context'
1417
import { looksLikeMermaid, MermaidDiagram } from '../mermaid-diagram'
1518
import { MarkdownCodeBlock } from './code-block-schema'
1619
import { detectLanguage } from './detect-language'
@@ -51,7 +54,8 @@ const CONTROL_CLASS =
5154
* whenever the cursor is outside it (and always in read-only), and as editable source while the
5255
* cursor is inside, re-rendering on blur (the Linear/GitHub model). The source `<pre>` stays mounted
5356
* (hidden behind the diagram) so ProseMirror keeps managing its contentDOM, and the node remains an
54-
* ordinary code block, so markdown round-trips unchanged.
57+
* ordinary code block, so markdown round-trips unchanged. A ```dashboard fence renders live
58+
* dashboard panels the same way.
5559
*/
5660
function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeViewProps) {
5761
const [wrap, setWrap] = useState(false)
@@ -60,16 +64,19 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
6064
const [peekSource, setPeekSource] = useState(false)
6165
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
6266
const editable = useEditorEditable(editor)
67+
const isStreaming = useContext(MarkdownStreamingContext)
6368

6469
const explicitLanguage = node.attrs.language as string | null
6570
const text = node.textContent
6671
const isMermaid = explicitLanguage === MERMAID || (!explicitLanguage && looksLikeMermaid(text))
72+
const isDashboard = explicitLanguage === DASHBOARD_EMBED_LANGUAGE
73+
const isRendered = isMermaid || isDashboard
6774

6875
// Editable Mermaid shows source while the caret is focused inside the block and re-renders the
6976
// diagram on blur (the Linear/GitHub model). The Show source / Show diagram control drives this by
7077
// focusing into / blurring the block; read-only uses {@link peekSource} since there is no caret.
7178
useEffect(() => {
72-
if (!isMermaid || !editable) {
79+
if (!isRendered || !editable) {
7380
setEditingInline(false)
7481
return
7582
}
@@ -92,13 +99,13 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
9299
editor.off('focus', sync)
93100
editor.off('blur', sync)
94101
}
95-
}, [editor, getPos, isMermaid, editable])
102+
}, [editor, getPos, isRendered, editable])
96103

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

100-
// Skip language detection on the mermaid path — the picker/label never render there.
101-
const language = explicitLanguage ?? (isMermaid ? null : detectLanguage(text)) ?? PLAIN
107+
// Skip language detection on rendered blocks — the picker/label never render there.
108+
const language = explicitLanguage ?? (isRendered ? null : detectLanguage(text)) ?? PLAIN
102109
const label =
103110
LANGUAGE_OPTIONS.find((option) => option.value === language)?.label ??
104111
explicitLanguage ??
@@ -133,18 +140,20 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
133140
)}
134141
contentEditable={false}
135142
>
136-
{isMermaid && (
143+
{isRendered && (
137144
<button
138145
type='button'
139-
aria-label={showSource ? 'Show diagram' : 'Show source'}
146+
aria-label={
147+
showSource ? (isDashboard ? 'Show dashboard' : 'Show diagram') : 'Show source'
148+
}
140149
onMouseDown={(event) => event.preventDefault()}
141150
onClick={toggleSource}
142151
className={CONTROL_CLASS}
143152
>
144153
{showSource ? <Eye /> : <Code />}
145154
</button>
146155
)}
147-
{!isMermaid &&
156+
{!isRendered &&
148157
(editable ? (
149158
// Editable: a language picker. Read-only: a static label — selecting a language calls
150159
// updateAttributes, which would mutate a doc that must not change.
@@ -180,7 +189,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
180189
{label}
181190
</span>
182191
))}
183-
{!isMermaid && editable && (
192+
{!isRendered && editable && (
184193
<button
185194
type='button'
186195
aria-label='Toggle line wrap'
@@ -205,22 +214,28 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
205214
{copied ? <Check /> : <Duplicate />}
206215
</button>
207216
</div>
208-
<pre className={cn('code-editor-theme pr-20', showDiagram && 'hidden')} data-wrap={wrap}>
217+
<pre className={cn('code-editor-theme pr-20', showRendered && 'hidden')} data-wrap={wrap}>
209218
<NodeViewContent<'code'> as='code' />
210219
</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.
220+
{showRendered && (
221+
// Select the whole node instead of placing a caret, which would flip the block to source.
215222
<div
216223
contentEditable={false}
224+
className={cn(isDashboard && 'dashboard-embed')}
217225
onMouseDown={(event) => {
226+
const target = event.target
227+
if (!(target instanceof Element) || !event.currentTarget.contains(target)) return
228+
if (target.closest('button, input')) return
218229
event.preventDefault()
219230
const pos = typeof getPos === 'function' ? getPos() : null
220231
if (typeof pos === 'number') editor.commands.setNodeSelection(pos)
221232
}}
222233
>
223-
<MermaidDiagram definition={text} className='mermaid-diagram-frame' />
234+
{isDashboard ? (
235+
<DashboardEmbed source={text} isStreaming={isStreaming} />
236+
) : (
237+
<MermaidDiagram definition={text} className='mermaid-diagram-frame' />
238+
)}
224239
</div>
225240
)}
226241
</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: 7 additions & 4 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'
@@ -1446,10 +1447,12 @@ export function LoadedRichMarkdownEditor({
14461447
workspaceId={workspaceId}
14471448
/>
14481449
)}
1449-
<EditorContent
1450-
editor={editor}
1451-
className={cn(EDITOR_SURFACE_CLASS, showPlaceholder && 'hidden')}
1452-
/>
1450+
<MarkdownStreamingContext value={isStreaming}>
1451+
<EditorContent
1452+
editor={editor}
1453+
className={cn(EDITOR_SURFACE_CLASS, showPlaceholder && 'hidden')}
1454+
/>
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
}

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

Lines changed: 39 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,26 @@ import {
1415
TextQuote,
1516
} from '@sim/emcn/icons'
1617
import type { Editor, Range } from '@tiptap/core'
18+
import { DASHBOARD_EMBED_LANGUAGE } from '@/lib/dashboards/spec'
19+
20+
/** A time-series starter; the table id is left for the author to fill in. */
21+
const DASHBOARD_EMBED_STARTER = `title: Rows over time
22+
time: 7d
23+
source:
24+
tableId: # table id
25+
blocks:
26+
- chart: Rows per day
27+
source:
28+
groupBy: [createdAt]
29+
bucket: day
30+
aggregate:
31+
rows: { op: count }
32+
option:
33+
xAxis: { type: time }
34+
yAxis: { type: value }
35+
series:
36+
- { type: line, encode: { x: createdAt, y: rows } }
37+
`
1738

1839
export interface SlashCommandContext {
1940
editor: Editor
@@ -121,6 +142,24 @@ export const SLASH_COMMANDS: readonly SlashCommandItem[] = [
121142
shortcut: '⌘⌥C',
122143
run: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
123144
},
145+
{
146+
title: 'Chart',
147+
group: 'Blocks',
148+
icon: ChartColumn,
149+
aliases: ['dashboard', 'graph', 'metric', 'live data'],
150+
run: ({ editor, range }) =>
151+
editor
152+
.chain()
153+
.focus()
154+
.deleteRange(range)
155+
.insertContent({
156+
type: 'codeBlock',
157+
attrs: { language: DASHBOARD_EMBED_LANGUAGE },
158+
content: [{ type: 'text', text: DASHBOARD_EMBED_STARTER }],
159+
})
160+
.setTextSelection(range.from + 1)
161+
.run(),
162+
},
124163
{
125164
title: 'Table',
126165
group: 'Blocks',

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

Lines changed: 21 additions & 3 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,21 +40,32 @@ 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))
58+
const next: { option: Record<string, unknown>; annotations?: ChartAnnotations } =
59+
JSON.parse(nextOption)
60+
const parsed = applyChartTooltipDefaults(
61+
next.annotations
62+
? applyChartAnnotations(
63+
next.option,
64+
next.annotations,
65+
readChartTonePalette(chart.getDom())
66+
)
67+
: next.option
68+
)
5169
chart.setOption(controller ? controller.prepareOption(parsed) : parsed, { notMerge: true })
5270
controller?.afterUpdate()
5371
rowHighlightRef.current?.()

‎apps/sim/components/charts/time-series-chart.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useRef, useState } from 'react'
44
import { cn, scrollFadeAttributes, scrollFadeXClass, useScrollEdges } from '@sim/emcn'
55
import { EChartsView } from '@/components/charts/echarts-view'
6+
import type { ChartAnnotations } from '@/lib/charts/annotations'
67
import {
78
bindTimeSeriesInteractions,
89
type ChartReadout,
@@ -13,9 +14,10 @@ import { dashboardTimeLabel } from '@/lib/dashboards/time'
1314
interface TimeSeriesChartProps extends Omit<TimeSeriesInteractionOptions, 'onReadout'> {
1415
label: string
1516
option: Record<string, unknown>
17+
annotations?: ChartAnnotations
1618
}
1719

18-
export function TimeSeriesChart({ label, option, ...config }: TimeSeriesChartProps) {
20+
export function TimeSeriesChart({ label, option, annotations, ...config }: TimeSeriesChartProps) {
1921
const valuesRef = useRef<HTMLDivElement>(null)
2022
const edges = useScrollEdges(valuesRef, { axis: 'x' })
2123
const [readout, setReadout] = useState<ChartReadout | null>(null)
@@ -58,6 +60,7 @@ export function TimeSeriesChart({ label, option, ...config }: TimeSeriesChartPro
5860
<EChartsView
5961
label={label}
6062
option={option}
63+
annotations={annotations}
6164
className='h-[240px]'
6265
revision={JSON.stringify({
6366
...config.range,

0 commit comments

Comments
 (0)