From 11669e99fde16aeea9aeb86e7088cbbad29ddeb0 Mon Sep 17 00:00:00 2001 From: Jan Librowski Date: Wed, 15 Jul 2026 11:10:36 +0200 Subject: [PATCH 1/4] fix(ai-studio): re-adapt visualization when the render format changes The adapted content was cached in bare state and reset only when the source text changed, so switching the render format kept feeding the previous format's adaptation to the new renderer (raw JSON instead of a chart) and never called the AI again. The adaptation is now an async derived value keyed by (renderer, text) in a useAdaptedVisualization hook: a stale entry is ignored by the key check instead of being reset by an effect, and each format change triggers a fresh adapt for that format. --- .../components/visualize/visualize-card.tsx | 44 +++++-------------- .../src/hooks/use-adapted-visualization.ts | 43 ++++++++++++++++++ 2 files changed, 53 insertions(+), 34 deletions(-) create mode 100644 apps/ai-studio/src/hooks/use-adapted-visualization.ts diff --git a/apps/ai-studio/src/components/visualize/visualize-card.tsx b/apps/ai-studio/src/components/visualize/visualize-card.tsx index ce2bb4b27..a31dc331a 100644 --- a/apps/ai-studio/src/components/visualize/visualize-card.tsx +++ b/apps/ai-studio/src/components/visualize/visualize-card.tsx @@ -1,12 +1,12 @@ import { ArrowsOut, DownloadSimple, Eye } from '@phosphor-icons/react'; import { getStoreEdges, getStoreNodes } from '@workflowbuilder/sdk'; -import { Suspense, useEffect, useRef, useState } from 'react'; +import { Suspense, useRef, useState } from 'react'; import styles from './visualize-card.module.css'; +import { useAdaptedVisualization } from '../../hooks/use-adapted-visualization'; import { VISUALIZE_MODES } from '../../nodes/visualize/schema'; import { useExecutionStore } from '../../stores/use-execution-store'; -import { adaptVisualization } from '../../utils/adapt-visualization'; import { type VisualizeRenderer, detectFormat } from '../../utils/detect-format'; import { downloadPng } from '../../utils/export-visualization'; import { extractOutputText } from '../../utils/extract-output-text'; @@ -22,7 +22,6 @@ type Props = { type VisualizeMode = VisualizeRenderer | 'auto'; const VALID_MODES = new Set(VISUALIZE_MODES); -const ADAPTABLE = new Set(['diagram', 'chart', 'table', 'json', 'stat-cards']); function EmptyState({ running }: { running: boolean }) { if (running) { @@ -47,9 +46,7 @@ function EmptyState({ running }: { running: boolean }) { export function VisualizeCard({ props }: Props) { const nodeId = props?.nodeId ?? ''; - const [expanded, setExpanded] = useState(false); - const [adaptedText, setAdaptedText] = useState(null); - const [adapting, setAdapting] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); const contentRef = useRef(null); // Nodes/edges are static during a run, so snapshot reads are fine. @@ -69,28 +66,7 @@ export function VisualizeCard({ props }: Props) { const detection = detectFormat(text); const activeRenderer: VisualizeRenderer = mode === 'auto' ? detection.renderer : mode; - useEffect(() => { - setAdaptedText(null); - }, [text]); - - useEffect(() => { - if (!hasOutput || !ADAPTABLE.has(activeRenderer) || adaptedText !== null) return; - let cancelled = false; - setAdapting(true); - adaptVisualization(text, activeRenderer) - .then((output) => { - if (!cancelled) setAdaptedText(output); - }) - .catch(() => { - // keep original content - }) - .finally(() => { - if (!cancelled) setAdapting(false); - }); - return () => { - cancelled = true; - }; - }, [hasOutput, activeRenderer, text, adaptedText]); + const { adaptedText, isAdapting } = useAdaptedVisualization(text, activeRenderer, hasOutput); if (!isVisualizeNode) { return null; @@ -109,20 +85,20 @@ export function VisualizeCard({ props }: Props) {
{badge}
- contentRef.current} text={renderText} - disabled={adapting} + disabled={isAdapting} />
- {adapting ? ( + {isAdapting ? (
@@ -151,14 +127,14 @@ export function VisualizeCard({ props }: Props) { ) : ( )} - {expanded && ( + {isExpanded && ( setExpanded(false)} + onClose={() => setIsExpanded(false)} /> )}
diff --git a/apps/ai-studio/src/hooks/use-adapted-visualization.ts b/apps/ai-studio/src/hooks/use-adapted-visualization.ts new file mode 100644 index 000000000..b6f3c02d9 --- /dev/null +++ b/apps/ai-studio/src/hooks/use-adapted-visualization.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from 'react'; + +import { adaptVisualization } from '../utils/adapt-visualization'; +import type { VisualizeRenderer } from '../utils/detect-format'; + +const ADAPTABLE = new Set(['diagram', 'chart', 'table', 'json', 'stat-cards']); + +type Adaptation = { key: string; output: string }; + +// The adapted content is an async derived value keyed by (renderer, text). +// A stale adaptation is ignored by the key check, never reset by an effect, +// so switching the render format re-adapts for the new one. +export function useAdaptedVisualization(text: string, renderer: VisualizeRenderer, hasOutput: boolean) { + const [adaptation, setAdaptation] = useState(null); + const [isAdapting, setIsAdapting] = useState(false); + + const adaptationKey = `${renderer}\n${text}`; + const adaptedText = adaptation?.key === adaptationKey ? adaptation.output : null; + const shouldAdapt = hasOutput && ADAPTABLE.has(renderer) && adaptedText === null; + + useEffect(() => { + if (!shouldAdapt) return; + + let cancelled = false; + setIsAdapting(true); + adaptVisualization(text, renderer) + .then((output) => { + if (!cancelled) setAdaptation({ key: adaptationKey, output }); + }) + .catch(() => { + // keep original content + }) + .finally(() => { + if (!cancelled) setIsAdapting(false); + }); + + return () => { + cancelled = true; + }; + }, [shouldAdapt, adaptationKey, text, renderer]); + + return { adaptedText, isAdapting }; +} From b4e0e992b2483203114723f93de472f963f1b5ff Mon Sep 17 00:00:00 2001 From: Jan Librowski Date: Wed, 15 Jul 2026 11:39:44 +0200 Subject: [PATCH 2/4] refactor(ai-studio): abort in-flight adapt calls, record failures as data The effect now uses an inner async function with an AbortController: switching formats mid-flight cancels the HTTP request instead of just ignoring its result, and the cancelled flag is gone. A failed adapt is cached as { key, output: null } - the raw-text fallback derives from data instead of a swallowed exception, so the catch block has real content and the failure is one selector away from the UI whenever we want to surface it. --- .../src/hooks/use-adapted-visualization.ts | 42 ++++++++++--------- .../src/utils/adapt-visualization.ts | 3 +- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/apps/ai-studio/src/hooks/use-adapted-visualization.ts b/apps/ai-studio/src/hooks/use-adapted-visualization.ts index b6f3c02d9..872e89655 100644 --- a/apps/ai-studio/src/hooks/use-adapted-visualization.ts +++ b/apps/ai-studio/src/hooks/use-adapted-visualization.ts @@ -5,7 +5,8 @@ import type { VisualizeRenderer } from '../utils/detect-format'; const ADAPTABLE = new Set(['diagram', 'chart', 'table', 'json', 'stat-cards']); -type Adaptation = { key: string; output: string }; +// output null = the adapt call failed and the raw text is rendered instead. +type Adaptation = { key: string; output: string | null }; // The adapted content is an async derived value keyed by (renderer, text). // A stale adaptation is ignored by the key check, never reset by an effect, @@ -15,29 +16,30 @@ export function useAdaptedVisualization(text: string, renderer: VisualizeRendere const [isAdapting, setIsAdapting] = useState(false); const adaptationKey = `${renderer}\n${text}`; - const adaptedText = adaptation?.key === adaptationKey ? adaptation.output : null; - const shouldAdapt = hasOutput && ADAPTABLE.has(renderer) && adaptedText === null; + const cached = adaptation?.key === adaptationKey ? adaptation : null; + const shouldAdapt = hasOutput && ADAPTABLE.has(renderer) && cached === null; useEffect(() => { if (!shouldAdapt) return; - let cancelled = false; - setIsAdapting(true); - adaptVisualization(text, renderer) - .then((output) => { - if (!cancelled) setAdaptation({ key: adaptationKey, output }); - }) - .catch(() => { - // keep original content - }) - .finally(() => { - if (!cancelled) setIsAdapting(false); - }); - - return () => { - cancelled = true; - }; + const controller = new AbortController(); + + async function adapt() { + setIsAdapting(true); + try { + const output = await adaptVisualization(text, renderer, controller.signal); + setAdaptation({ key: adaptationKey, output }); + } catch { + if (!controller.signal.aborted) setAdaptation({ key: adaptationKey, output: null }); + } finally { + if (!controller.signal.aborted) setIsAdapting(false); + } + } + + void adapt(); + + return () => controller.abort(); }, [shouldAdapt, adaptationKey, text, renderer]); - return { adaptedText, isAdapting }; + return { adaptedText: cached?.output ?? null, isAdapting }; } diff --git a/apps/ai-studio/src/utils/adapt-visualization.ts b/apps/ai-studio/src/utils/adapt-visualization.ts index 1f7a94026..3be48f2a5 100644 --- a/apps/ai-studio/src/utils/adapt-visualization.ts +++ b/apps/ai-studio/src/utils/adapt-visualization.ts @@ -1,7 +1,7 @@ import { BACKEND_URL } from '../config'; import { getTurnstileToken } from '../security/turnstile'; -export async function adaptVisualization(content: string, format: string): Promise { +export async function adaptVisualization(content: string, format: string, signal?: AbortSignal): Promise { const token = await getTurnstileToken(); const response = await fetch(`${BACKEND_URL}/api/visualize/adapt`, { method: 'POST', @@ -10,6 +10,7 @@ export async function adaptVisualization(content: string, format: string): Promi ...(token ? { 'cf-turnstile-token': token } : {}), }, body: JSON.stringify({ content, format }), + signal, }); if (!response.ok) { const error = (await response.json().catch(() => ({}))) as { message?: string }; From 1fab8f212dc3a17753cb88c9027342d5d0ded184 Mon Sep 17 00:00:00 2001 From: Jan Librowski Date: Wed, 15 Jul 2026 12:49:47 +0200 Subject: [PATCH 3/4] refactor(ai-studio): return render-ready text from useAdaptedVisualization The hook now owns the raw-text fallback and returns renderText plus an isAdapted flag, so the caller no longer interprets a null convention spread across files. --- .../src/components/visualize/visualize-card.tsx | 5 ++--- apps/ai-studio/src/hooks/use-adapted-visualization.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/ai-studio/src/components/visualize/visualize-card.tsx b/apps/ai-studio/src/components/visualize/visualize-card.tsx index a31dc331a..96b909bfd 100644 --- a/apps/ai-studio/src/components/visualize/visualize-card.tsx +++ b/apps/ai-studio/src/components/visualize/visualize-card.tsx @@ -66,14 +66,13 @@ export function VisualizeCard({ props }: Props) { const detection = detectFormat(text); const activeRenderer: VisualizeRenderer = mode === 'auto' ? detection.renderer : mode; - const { adaptedText, isAdapting } = useAdaptedVisualization(text, activeRenderer, hasOutput); + const { renderText, isAdapted, isAdapting } = useAdaptedVisualization(text, activeRenderer, hasOutput); if (!isVisualizeNode) { return null; } - const renderText = adaptedText ?? text; - const data = adaptedText === null && mode === 'auto' ? detection.data : undefined; + const data = !isAdapted && mode === 'auto' ? detection.data : undefined; const Renderer = hasOutput ? getRenderer(activeRenderer) : null; const badge = mode === 'auto' ? `Auto › ${RENDERER_LABELS[activeRenderer]}` : RENDERER_LABELS[activeRenderer]; const isVector = activeRenderer === 'chart' || activeRenderer === 'diagram'; diff --git a/apps/ai-studio/src/hooks/use-adapted-visualization.ts b/apps/ai-studio/src/hooks/use-adapted-visualization.ts index 872e89655..48ca82e73 100644 --- a/apps/ai-studio/src/hooks/use-adapted-visualization.ts +++ b/apps/ai-studio/src/hooks/use-adapted-visualization.ts @@ -41,5 +41,12 @@ export function useAdaptedVisualization(text: string, renderer: VisualizeRendere return () => controller.abort(); }, [shouldAdapt, adaptationKey, text, renderer]); - return { adaptedText: cached?.output ?? null, isAdapting }; + const adaptedOutput = cached?.output ?? null; + + return { + /** Adapted content when available, the raw text otherwise. */ + renderText: adaptedOutput ?? text, + isAdapted: adaptedOutput !== null, + isAdapting, + }; } From 9e2eac90ee1d44108dc11277fa86efb5cbda5ef0 Mon Sep 17 00:00:00 2001 From: Jan Librowski Date: Wed, 15 Jul 2026 12:51:16 +0200 Subject: [PATCH 4/4] refactor(ai-studio): return render-ready text from useAdaptedVisualization The hook now owns the raw-text fallback and returns renderText plus an isAdapted flag, so the caller no longer interprets a null convention spread across files. --- apps/ai-studio/src/hooks/use-adapted-visualization.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/ai-studio/src/hooks/use-adapted-visualization.ts b/apps/ai-studio/src/hooks/use-adapted-visualization.ts index 48ca82e73..701959242 100644 --- a/apps/ai-studio/src/hooks/use-adapted-visualization.ts +++ b/apps/ai-studio/src/hooks/use-adapted-visualization.ts @@ -44,7 +44,6 @@ export function useAdaptedVisualization(text: string, renderer: VisualizeRendere const adaptedOutput = cached?.output ?? null; return { - /** Adapted content when available, the raw text otherwise. */ renderText: adaptedOutput ?? text, isAdapted: adaptedOutput !== null, isAdapting,