From 4957b58190fbaf2d836195cfe2a9777559864e8b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Apr 2026 15:06:17 +0000 Subject: [PATCH 1/2] feat(chart): UI to edit display_chart params and persist to db --- apps/backend/src/queries/chart-image.ts | 20 ++ apps/backend/src/trpc/chart.routes.ts | 36 ++- .../tool-calls/display-chart-edit-dialog.tsx | 304 ++++++++++++++++++ .../components/tool-calls/display-chart.tsx | 25 +- apps/frontend/src/contexts/agent.provider.tsx | 1 + apps/frontend/src/hooks/use-agent.ts | 1 + package-lock.json | 130 ++++---- 7 files changed, 443 insertions(+), 74 deletions(-) create mode 100644 apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx diff --git a/apps/backend/src/queries/chart-image.ts b/apps/backend/src/queries/chart-image.ts index 5b2488295..23b14f0bd 100644 --- a/apps/backend/src/queries/chart-image.ts +++ b/apps/backend/src/queries/chart-image.ts @@ -66,3 +66,23 @@ export const saveChart = async (toolCallId: string, data: string): Promise => { + const [row] = await db + .select({ projectId: s.chat.projectId, userId: s.chat.userId }) + .from(s.messagePart) + .innerJoin(s.chatMessage, eq(s.messagePart.messageId, s.chatMessage.id)) + .innerJoin(s.chat, eq(s.chatMessage.chatId, s.chat.id)) + .where(eq(s.messagePart.toolCallId, toolCallId)) + .execute(); + return row ?? null; +}; + +/** Persists an updated `display_chart` config for the given tool call. */ +export const updateChartConfig = async (toolCallId: string, config: displayChart.Input): Promise => { + await db.update(s.messagePart).set({ toolInput: config }).where(eq(s.messagePart.toolCallId, toolCallId)).execute(); + + // Invalidate any cached PNG so externally-served chart images refresh. + await db.delete(s.message_part_chart_image).where(eq(s.message_part_chart_image.toolCallId, toolCallId)).execute(); +}; diff --git a/apps/backend/src/trpc/chart.routes.ts b/apps/backend/src/trpc/chart.routes.ts index 0fafce801..9b0bb9ba1 100644 --- a/apps/backend/src/trpc/chart.routes.ts +++ b/apps/backend/src/trpc/chart.routes.ts @@ -1,8 +1,15 @@ +import { displayChart } from '@nao/shared/tools'; +import { TRPCError } from '@trpc/server'; import { z } from 'zod/v4'; import { generateChartImage } from '../components/generate-chart'; -import { getChartConfigByToolCallId, getChartDataByQueryId } from '../queries/chart-image'; -import { projectProtectedProcedure } from './trpc'; +import { + getChartConfigByToolCallId, + getChartDataByQueryId, + getChartOwnerInfo, + updateChartConfig, +} from '../queries/chart-image'; +import { projectProtectedProcedure, protectedProcedure } from './trpc'; export const chartRoutes = { download: projectProtectedProcedure @@ -17,4 +24,29 @@ export const chartRoutes = { const png = generateChartImage({ config, data }); return png.toString('base64'); }), + + updateConfig: protectedProcedure + .input( + z.object({ + toolCallId: z.string(), + config: z.custom((value) => displayChart.InputSchema.safeParse(value).success, { + message: 'Invalid chart config', + }), + }), + ) + .mutation(async ({ input, ctx }) => { + const owner = await getChartOwnerInfo(input.toolCallId); + if (!owner) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Chart not found.' }); + } + if (owner.userId !== ctx.user.id) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You are not authorized to edit this chart.', + }); + } + + await updateChartConfig(input.toolCallId, input.config); + return { success: true as const }; + }), }; diff --git a/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx b/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx new file mode 100644 index 000000000..7c9791d1a --- /dev/null +++ b/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx @@ -0,0 +1,304 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Trash2 } from 'lucide-react'; +import { displayChart } from '@nao/shared/tools'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; +import type { UIMessage, UIToolPart } from '@nao/backend/chat'; +import { useAgentContext } from '@/contexts/agent.provider'; +import { trpc } from '@/main'; + +const CHART_TYPE_OPTIONS: { value: displayChart.ChartType; label: string }[] = [ + { value: 'bar', label: 'Bar' }, + { value: 'stacked_bar', label: 'Stacked bar' }, + { value: 'line', label: 'Line' }, + { value: 'area', label: 'Area' }, + { value: 'stacked_area', label: 'Stacked area' }, + { value: 'pie', label: 'Pie' }, + { value: 'kpi_card', label: 'KPI card' }, + { value: 'scatter', label: 'Scatter' }, + { value: 'radar', label: 'Radar' }, +]; + +const X_AXIS_TYPE_OPTIONS: { value: NonNullable | 'auto'; label: string }[] = [ + { value: 'auto', label: 'Auto' }, + { value: 'category', label: 'Category' }, + { value: 'date', label: 'Date' }, + { value: 'number', label: 'Number' }, +]; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + toolCallId: string; + config: displayChart.Input; + availableColumns: string[]; +} + +export function DisplayChartEditDialog({ open, onOpenChange, toolCallId, config, availableColumns }: Props) { + const queryClient = useQueryClient(); + const { messages, setMessages } = useAgentContext(); + const [draft, setDraft] = useState(config); + const [error, setError] = useState(null); + + useEffect(() => { + if (open) { + setDraft(config); + setError(null); + } + }, [open, config]); + + const updateMutation = useMutation( + trpc.chart.updateConfig.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [['chat', 'get']] }); + }, + }), + ); + + const xAxisOptions = useMemo(() => { + if (availableColumns.length === 0) { + return [config.x_axis_key]; + } + return availableColumns; + }, [availableColumns, config.x_axis_key]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + const parsed = displayChart.InputSchema.safeParse(draft); + if (!parsed.success) { + setError(parsed.error.issues[0]?.message ?? 'Invalid chart configuration.'); + return; + } + + const next = parsed.data; + const previousMessages = messages; + setMessages(applyChartConfigToMessages(previousMessages, toolCallId, next)); + + try { + await updateMutation.mutateAsync({ toolCallId, config: next }); + onOpenChange(false); + } catch (err) { + setMessages(previousMessages); + setError(err instanceof Error ? err.message : 'Failed to update chart.'); + } + }; + + const updateSeriesAt = (index: number, patch: Partial) => { + setDraft((prev) => ({ + ...prev, + series: prev.series.map((s, i) => (i === index ? { ...s, ...patch } : s)), + })); + }; + + const removeSeriesAt = (index: number) => { + setDraft((prev) => ({ + ...prev, + series: prev.series.length <= 1 ? prev.series : prev.series.filter((_, i) => i !== index), + })); + }; + + const addSeries = () => { + const used = new Set(draft.series.map((s) => s.data_key)); + const fallback = + availableColumns.find((c) => c !== draft.x_axis_key && !used.has(c)) ?? availableColumns[0] ?? ''; + setDraft((prev) => ({ + ...prev, + series: [...prev.series, { data_key: fallback }], + })); + }; + + return ( + + + + Edit chart + Tweak the chart parameters. Changes are saved to the chat. + + +
+
+ + setDraft((prev) => ({ ...prev, title: e.target.value }))} + placeholder='Chart title' + /> +
+ +
+
+ Chart type + +
+ +
+ X-axis type + +
+
+ +
+ X-axis column + setDraft((prev) => ({ ...prev, x_axis_key: value }))} + /> +
+ +
+
+ Series + +
+
+ {draft.series.map((series, index) => ( +
+ 0 ? availableColumns : [series.data_key]} + onChange={(value) => updateSeriesAt(index, { data_key: value })} + /> + updateSeriesAt(index, { label: e.target.value || undefined })} + placeholder='Label (optional)' + className='h-9 text-sm' + /> + updateSeriesAt(index, { color: e.target.value })} + className='h-9 w-9 cursor-pointer rounded-md border border-input bg-transparent p-0.5' + /> + +
+ ))} +
+
+ + {error &&

{error}

} + + + + + +
+
+
+ ); +} + +interface ColumnSelectProps { + value: string; + columns: string[]; + onChange: (value: string) => void; +} + +function ColumnSelect({ value, columns, onChange }: ColumnSelectProps) { + const items = columns.includes(value) ? columns : [value, ...columns]; + return ( + + ); +} + +const HEX_RE = /^#[0-9a-fA-F]{6}$/; +function normalizeHexColor(color?: string): string { + if (color && HEX_RE.test(color)) { + return color; + } + return '#104e64'; +} + +function applyChartConfigToMessages( + messages: UIMessage[], + toolCallId: string, + config: displayChart.Input, +): UIMessage[] { + return messages.map((message) => { + let changed = false; + const parts = message.parts.map((part) => { + if (part.type !== 'tool-display_chart') { + return part; + } + const toolPart = part as UIToolPart<'display_chart'>; + if (toolPart.toolCallId !== toolCallId) { + return part; + } + changed = true; + return { ...toolPart, input: config } as typeof part; + }); + return changed ? { ...message, parts } : message; + }); +} diff --git a/apps/frontend/src/components/tool-calls/display-chart.tsx b/apps/frontend/src/components/tool-calls/display-chart.tsx index 71543f2fb..5e6c5e3fc 100644 --- a/apps/frontend/src/components/tool-calls/display-chart.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart.tsx @@ -1,6 +1,6 @@ import { memo, useCallback, useMemo, useState } from 'react'; import { buildChart, labelize } from '@nao/shared'; -import { Download, FilePlus } from 'lucide-react'; +import { Download, FilePlus, Pencil } from 'lucide-react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useOptionalAgentContext } from '../../contexts/agent.provider'; import { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent } from '../ui/chart'; @@ -9,6 +9,7 @@ import { Skeleton } from '../ui/skeleton'; import { Button } from '../ui/button'; import { ToolCallWrapper } from './tool-call-wrapper'; import { ChartRangeSelector } from './display-chart-range-selector'; +import { DisplayChartEditDialog } from './display-chart-edit-dialog'; import type { ToolCallComponentProps } from '.'; import type { ChartConfig } from '../ui/chart'; import type { displayChart } from '@nao/shared/tools'; @@ -55,6 +56,8 @@ export const DisplayChartToolCall = ({ ); const [isDownloading, setIsDownloading] = useState(false); + const [isEditOpen, setIsEditOpen] = useState(false); + const isEditable = Boolean(agent && !agent.isReadonly && !agent.isRunning); const handleDownload = async () => { if (!config) { @@ -208,6 +211,16 @@ export const DisplayChartToolCall = ({ onRangeSelected={(range) => setDataRange(range)} /> )} + {isEditable && ( + + )} {config.chart_type != 'kpi_card' && ( + )} + {children} + {canEdit && edit && chart.rawTag && ( + edit.saveChart(chart.rawTag!, next)} + description='Tweak the chart parameters. Changes are saved to the story as a new version.' + /> + )} + + ); +} diff --git a/apps/frontend/src/components/side-panel/story-editor.tsx b/apps/frontend/src/components/side-panel/story-editor.tsx index 9f1435263..721b89a7f 100644 --- a/apps/frontend/src/components/side-panel/story-editor.tsx +++ b/apps/frontend/src/components/side-panel/story-editor.tsx @@ -68,7 +68,8 @@ function ChartBlockView({ node }: ReactNodeViewProps) { if (!attrMatch) { return null; } - return parseChartBlock(attrMatch[1]); + const parsed = parseChartBlock(attrMatch[1]); + return parsed ? { ...parsed, rawTag } : null; }, [rawTag]); if (!chart) { diff --git a/apps/frontend/src/components/side-panel/story-viewer.tsx b/apps/frontend/src/components/side-panel/story-viewer.tsx index 1078a2ccc..a50b9804a 100644 --- a/apps/frontend/src/components/side-panel/story-viewer.tsx +++ b/apps/frontend/src/components/side-panel/story-viewer.tsx @@ -20,6 +20,7 @@ import { useStoryViewerViewMode } from './hooks/use-story-viewer-view-mode'; import type { Editor as TiptapEditor } from '@tiptap/react'; import { useSidePanel } from '@/contexts/side-panel'; import { ReadonlyAgentMessagesProvider, useOptionalAgentContext } from '@/contexts/agent.provider'; +import { StoryChartEditProvider } from '@/contexts/story-chart-edit'; import { Spinner } from '@/components/ui/spinner'; import { chatActivityStore } from '@/stores/chat-activity'; import { trpc } from '@/main'; @@ -169,18 +170,27 @@ export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp }: {Boolean(archivedAt) && }
- {viewMode === 'preview' ? ( - - ) : viewMode === 'edit' ? ( - - ) : ( - + {renderWithEditProvider( + !isReadonlyMode && isViewingLatest && !archivedAt && !isAgentRunning && viewMode !== 'edit', + { + chatId, + storySlug: resolvedStorySlug, + storyTitle, + storyCode, + }, + viewMode === 'preview' ? ( + + ) : viewMode === 'edit' ? ( + + ) : ( + + ), )}
@@ -210,3 +220,23 @@ export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp }: return {content}; } + +function renderWithEditProvider( + enabled: boolean, + params: { chatId: string; storySlug: string; storyTitle: string; storyCode: string }, + children: React.ReactNode, +) { + if (!enabled) { + return children; + } + return ( + + {children} + + ); +} diff --git a/apps/frontend/src/components/story-embeds.tsx b/apps/frontend/src/components/story-embeds.tsx index e9573063e..3a7ca83d6 100644 --- a/apps/frontend/src/components/story-embeds.tsx +++ b/apps/frontend/src/components/story-embeds.tsx @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'; import type { ParsedChartBlock, ParsedTableBlock } from '@nao/shared/story-segments'; import type { displayChart } from '@nao/shared/tools'; +import { StoryChartEmbedShell } from '@/components/side-panel/story-chart-embed'; import { ChartDisplay } from '@/components/tool-calls/display-chart'; import { TableDisplay } from '@/components/tool-calls/display-table'; @@ -54,9 +55,11 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({ }) { const noCacheFetch = useLiveQueryData(chart.queryId, liveQuery); - const resolvedData = liveQuery - ? (noCacheFetch.data as { data: Record[]; columns: string[] } | undefined)?.data - : queryData?.[chart.queryId]?.data; + const resolved = liveQuery + ? (noCacheFetch.data as { data: Record[]; columns: string[] } | undefined) + : queryData?.[chart.queryId]; + const resolvedData = resolved?.data; + const resolvedColumns = resolved?.columns ?? []; if (liveQuery && noCacheFetch.isLoading) { return ; @@ -71,7 +74,7 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({ } return ( -
+ -
+ ); }); diff --git a/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx b/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx index 7c9791d1a..3d849ca3c 100644 --- a/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx @@ -29,17 +29,26 @@ const X_AXIS_TYPE_OPTIONS: { value: NonNullable | 'auto' { value: 'number', label: 'Number' }, ]; -interface Props { +interface ChartConfigEditDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - toolCallId: string; config: displayChart.Input; availableColumns: string[]; + onSave: (next: displayChart.Input) => Promise; + isSaving?: boolean; + description?: string; } -export function DisplayChartEditDialog({ open, onOpenChange, toolCallId, config, availableColumns }: Props) { - const queryClient = useQueryClient(); - const { messages, setMessages } = useAgentContext(); +/** Presentational edit dialog for `display_chart` configuration. */ +export function ChartConfigEditDialog({ + open, + onOpenChange, + config, + availableColumns, + onSave, + isSaving = false, + description = 'Tweak the chart parameters.', +}: ChartConfigEditDialogProps) { const [draft, setDraft] = useState(config); const [error, setError] = useState(null); @@ -50,14 +59,6 @@ export function DisplayChartEditDialog({ open, onOpenChange, toolCallId, config, } }, [open, config]); - const updateMutation = useMutation( - trpc.chart.updateConfig.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [['chat', 'get']] }); - }, - }), - ); - const xAxisOptions = useMemo(() => { if (availableColumns.length === 0) { return [config.x_axis_key]; @@ -73,15 +74,10 @@ export function DisplayChartEditDialog({ open, onOpenChange, toolCallId, config, return; } - const next = parsed.data; - const previousMessages = messages; - setMessages(applyChartConfigToMessages(previousMessages, toolCallId, next)); - try { - await updateMutation.mutateAsync({ toolCallId, config: next }); + await onSave(parsed.data); onOpenChange(false); } catch (err) { - setMessages(previousMessages); setError(err instanceof Error ? err.message : 'Failed to update chart.'); } }; @@ -115,7 +111,7 @@ export function DisplayChartEditDialog({ open, onOpenChange, toolCallId, config, Edit chart - Tweak the chart parameters. Changes are saved to the chat. + {description}
@@ -239,7 +235,7 @@ export function DisplayChartEditDialog({ open, onOpenChange, toolCallId, config, - @@ -249,6 +245,57 @@ export function DisplayChartEditDialog({ open, onOpenChange, toolCallId, config, ); } +interface DisplayChartEditDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + toolCallId: string; + config: displayChart.Input; + availableColumns: string[]; +} + +/** Edit dialog bound to a `tool-display_chart` message part: persists through `chart.updateConfig`. */ +export function DisplayChartEditDialog({ + open, + onOpenChange, + toolCallId, + config, + availableColumns, +}: DisplayChartEditDialogProps) { + const queryClient = useQueryClient(); + const { messages, setMessages } = useAgentContext(); + + const updateMutation = useMutation( + trpc.chart.updateConfig.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [['chat', 'get']] }); + }, + }), + ); + + const handleSave = async (next: displayChart.Input) => { + const previousMessages = messages; + setMessages(applyChartConfigToMessages(previousMessages, toolCallId, next)); + try { + await updateMutation.mutateAsync({ toolCallId, config: next }); + } catch (err) { + setMessages(previousMessages); + throw err; + } + }; + + return ( + + ); +} + interface ColumnSelectProps { value: string; columns: string[]; diff --git a/apps/frontend/src/components/tool-calls/display-chart.tsx b/apps/frontend/src/components/tool-calls/display-chart.tsx index 5e6c5e3fc..88190acbc 100644 --- a/apps/frontend/src/components/tool-calls/display-chart.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart.tsx @@ -1,5 +1,6 @@ import { memo, useCallback, useMemo, useState } from 'react'; import { buildChart, labelize } from '@nao/shared'; +import { buildChartTag } from '@nao/shared/story-segments'; import { Download, FilePlus, Pencil } from 'lucide-react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useOptionalAgentContext } from '../../contexts/agent.provider'; @@ -25,9 +26,6 @@ import { trpc } from '@/main'; const Colors = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)']; const EMPTY_MESSAGES: UIMessage[] = []; -const escapeDoubleQuotedAttr = (value: string) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); -const escapeSingleQuotedAttr = (value: string) => value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); - export const DisplayChartToolCall = ({ toolPart: { state, input, output, toolCallId }, }: ToolCallComponentProps<'display_chart'>) => { @@ -168,8 +166,7 @@ export const DisplayChartToolCall = ({ return; } - const seriesJson = JSON.stringify(config.series); - const chartBlock = ``; + const chartBlock = buildChartTag(config); const newCode = latest.code.trimEnd() + '\n\n' + chartBlock; addToStoryMutation.mutate({ diff --git a/apps/frontend/src/contexts/story-chart-edit.tsx b/apps/frontend/src/contexts/story-chart-edit.tsx new file mode 100644 index 000000000..27f7598a9 --- /dev/null +++ b/apps/frontend/src/contexts/story-chart-edit.tsx @@ -0,0 +1,98 @@ +import { createContext, useCallback, useContext, useMemo } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { buildChartTag } from '@nao/shared/story-segments'; +import type { displayChart } from '@nao/shared/tools'; +import { trpc } from '@/main'; + +export interface StoryChartEditHandlers { + /** + * Persists a new chart config by replacing `rawTag` (the original `` tag) + * in the story's markdown and saving a new version. + * Returns a promise that rejects if the save fails. + */ + saveChart: (rawTag: string, config: displayChart.Input) => Promise; + /** Whether a save is currently in flight. */ + isSaving: boolean; +} + +const StoryChartEditContext = createContext(null); + +export const useStoryChartEdit = () => useContext(StoryChartEditContext); + +interface StoryChartEditProviderProps { + chatId: string; + storySlug: string; + storyTitle: string; + storyCode: string; + children: React.ReactNode; +} + +/** + * Provides a `saveChart` handler that chart embeds inside a story can call to persist + * edits (title/type/series/etc) back to the story via `story.createVersion`. + */ +export function StoryChartEditProvider({ + chatId, + storySlug, + storyTitle, + storyCode, + children, +}: StoryChartEditProviderProps) { + const queryClient = useQueryClient(); + const latestStoryQueryKey = trpc.story.getLatest.queryKey({ chatId, storySlug }); + + const createVersionMutation = useMutation( + trpc.story.createVersion.mutationOptions({ + onMutate: async (variables) => { + await queryClient.cancelQueries({ queryKey: latestStoryQueryKey }); + const previousLatestStory = queryClient.getQueryData(latestStoryQueryKey); + queryClient.setQueryData(latestStoryQueryKey, (latestStory) => + latestStory && typeof latestStory === 'object' + ? { ...latestStory, code: variables.code } + : latestStory, + ); + return { previousLatestStory }; + }, + onError: (_error, _variables, context) => { + if (context?.previousLatestStory !== undefined) { + queryClient.setQueryData(latestStoryQueryKey, context.previousLatestStory); + } + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: trpc.story.listVersions.queryKey({ chatId, storySlug }), + }); + void queryClient.invalidateQueries({ queryKey: trpc.story.listAll.queryKey() }); + void queryClient.invalidateQueries({ queryKey: latestStoryQueryKey }); + }, + }), + ); + + const saveChart = useCallback( + async (rawTag: string, config: displayChart.Input) => { + if (!storyCode.includes(rawTag)) { + throw new Error('Could not locate the chart in the current story version.'); + } + const nextTag = buildChartTag(config); + const nextCode = storyCode.replace(rawTag, nextTag); + if (nextCode === storyCode) { + return; + } + await createVersionMutation.mutateAsync({ + chatId, + storySlug, + title: storyTitle, + code: nextCode, + action: 'replace', + }); + }, + [chatId, storySlug, storyTitle, storyCode, createVersionMutation], + ); + + const value = useMemo( + () => ({ saveChart, isSaving: createVersionMutation.isPending }), + [saveChart, createVersionMutation.isPending], + ); + + return {children}; +} diff --git a/apps/frontend/src/routes/_sidebar-layout.stories.preview.$chatId.$storySlug.tsx b/apps/frontend/src/routes/_sidebar-layout.stories.preview.$chatId.$storySlug.tsx index 78def7504..24f817af6 100644 --- a/apps/frontend/src/routes/_sidebar-layout.stories.preview.$chatId.$storySlug.tsx +++ b/apps/frontend/src/routes/_sidebar-layout.stories.preview.$chatId.$storySlug.tsx @@ -15,6 +15,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import { trpc } from '@/main'; import { StoryDownload } from '@/components/story-download'; import { SelectionProvider } from '@/contexts/text-selection'; +import { StoryChartEditProvider } from '@/contexts/story-chart-edit'; import { chatPendingCitationStore } from '@/stores/chat-pending-citation'; import { useChatActivity } from '@/hooks/use-chat-activity'; @@ -129,12 +130,19 @@ function StoryPreviewPage() { - + storySlug={storySlug} + storyTitle={story.title} + storyCode={story.code} + > + + ); diff --git a/apps/shared/src/story-segments.ts b/apps/shared/src/story-segments.ts index 299de8e01..9401de95b 100644 --- a/apps/shared/src/story-segments.ts +++ b/apps/shared/src/story-segments.ts @@ -5,6 +5,8 @@ export interface ParsedChartBlock { xAxisType: string | null; series: Array<{ data_key: string; color: string; label?: string }>; title: string; + /** The original `` tag this block was parsed from, when available. */ + rawTag?: string; } export interface ParsedTableBlock { @@ -62,6 +64,26 @@ export function parseChartBlock(attrString: string): ParsedChartBlock | null { }; } +const escapeDoubleQuotedAttr = (value: string) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +const escapeSingleQuotedAttr = (value: string) => value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + +/** Serializes a chart config into its `` tag representation used in story markdown. */ +export function buildChartTag(config: { + query_id: string; + chart_type: string; + x_axis_key: string; + x_axis_type: string | null; + series: Array<{ data_key: string; color?: string; label?: string }>; + title: string; +}): string { + const seriesJson = JSON.stringify(config.series); + return ``; +} + export function parseTableBlock(attrString: string): ParsedTableBlock | null { const attrs = parseChartAttributes(attrString); if (!attrs.query_id) { @@ -141,7 +163,7 @@ export function splitCodeIntoSegments(code: string): Segment[] { } else if (match[3] !== undefined) { const chart = parseChartBlock(match[3]); if (chart) { - segments.push({ type: 'chart', chart }); + segments.push({ type: 'chart', chart: { ...chart, rawTag: match[0] } }); } } else if (match[4] !== undefined) { const table = parseTableBlock(match[4]);