/** * runs the resolve → render pipeline, manages streaming/cache, * and exposes derived state for the dumb UI. See lib/graph/rendering.ts for the * pipeline interface. */ import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' import { useAbstractNode } from '@/lib/graph/abstractNode' import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle' import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore' import { usePlatform } from '@/app/kosmos/KosmosContext' import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils' import { getDefaultStyle } from '@/lib/graph/nodeRegistry' import { getConfigContent, getConfigType, getConfigTypeId, getSourceRenderingLogic, parseThinkSections, processSvgDisplay, stripTemplateSyntax, } from '@/lib/graph/rendering' import { buildSourceSignatures, type NodeLike, type EdgeLike } from '@/lib/graph/renderingSignatures' import { getNodeDisplayStatus, type NodeDisplayStatus } from '@/lib/graph/state' import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering' export type RenderingNodeData = { viewportWidth?: number viewportHeight?: number updateMode?: 'auto' | 'manual' runTrigger?: number lastRunSourceSignature?: string cachedRenderedContent?: string cachedResolvedContent?: string cachedReasoningContent?: string } const DEFAULT_VIEWPORT_WIDTH = 1200 const DEFAULT_VIEWPORT_HEIGHT = 800 const RENDER_DEBOUNCE_MS = 250 /** Lifecycle state to pass to useSyncConnectionStatus so edge status (updating/error) stays in sync. */ export type RenderingNodeLifecycle = { updating: boolean error: boolean } export type RenderingNodeState = { /** For NodeStatusIndicator and empty/error UI. Derived from loading, error, renderedContent. */ displayStatus: NodeDisplayStatus /** Pass to useSyncConnectionStatus so edges show correct status (blue/yellow/red). */ lifecycle: RenderingNodeLifecycle // Connection & run control incomingIds: string[] effectiveUpdateMode: 'auto' | 'manual' runTrigger: number hasPendingInputs: boolean loading: boolean error: null | { kind: string; message: string } incrementRunTrigger: () => void setUpdateMode: (mode: 'auto' | 'manual') => void setRetryCount: (fn: (c: number) => number) => void // Content (resolved = before render, rendered = after render) renderedContent: string | null resolvedContent: string | null streamingMarkdown: string | null streamingPreviewHtml: string reasoningContent: string reasoningHtml: string // Display type (no config/agent types exposed) outputType: 'html' | 'image' outputLabel: string rawLanguage: ConfigTypeId isSvgOutput: boolean // Derived for UI renderedThinkSplit: { main: string; think: string } streamingThinkSplit: { main: string; think: string } rawDisplayContent: string displayContent: string | null // Viewport viewportWidth: number viewportHeight: number // Empty state (source-type-specific action stays in hook) emptyStateAction: null | { label: string; onClick: () => void } // Optional custom error UI from source node data sourceData: Record /** Source node type id (e.g. 'config', 'agent') for Output menu from descriptor. */ sourceNodeType: string | null } export function useRenderingNodeState( id: string, data: RenderingNodeData | undefined ): RenderingNodeState { const { nodes: contextNodes, edges: contextEdges, setNodes, setEdges, sourceIds: incomingIds, updateData, } = useAbstractNode(id, data ?? {}) // Subscribe to store graph so we re-render when any node/edge changes (e.g. ascendant data). // Context only exposes a ref, so we wouldn't re-render when another node updates otherwise. const storeNodes = useCanvasStore((s) => s.graph.nodes) const storeEdges = useCanvasStore((s) => s.graph.edges) const nodes = storeNodes.length > 0 ? storeNodes : contextNodes const edges = storeEdges.length > 0 ? storeEdges : contextEdges const nodesEdgesRef = useRef({ nodes, edges }) nodesEdgesRef.current = { nodes, edges } const { aiConnection } = usePlatform() const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT const deferredNodes = useDeferredValue(nodes) const deferredEdges = useDeferredValue(edges) const srcId = incomingIds.length > 0 ? incomingIds[0] : null const srcNode = useMemo( () => (srcId ? nodes.find((n: { id: string }) => n.id === srcId) : null), [nodes, srcId] ) const sourceLogic = useMemo( () => (srcNode?.type ? getSourceRenderingLogic(srcNode.type as string) : null), [srcNode?.type] ) const effectiveUpdateMode = (data?.updateMode ?? sourceLogic?.defaultUpdateMode ?? 'auto') as 'auto' | 'manual' const runTrigger = data?.runTrigger ?? 0 const isAgentSource = srcNode?.type === 'agent' const agentOutputMarkdown = isAgentSource ? ((srcNode?.data as { outputMarkdown?: string })?.outputMarkdown ?? '') : '' const configTypeId: ConfigTypeId = srcNode?.type === 'config' ? getConfigTypeId((srcNode?.data ?? undefined) as Record | undefined) : isAgentSource ? 'markdown' : 'plantuml' const configType = getConfigType(configTypeId) const outputType = configType.outputType const sourceContent = srcNode?.type === 'config' ? getConfigContent((srcNode?.data ?? undefined) as Record | undefined) : isAgentSource ? agentOutputMarkdown : '' const signatures = useMemo( () => buildSourceSignatures(deferredNodes as NodeLike[], deferredEdges as EdgeLike[], id, incomingIds), [deferredNodes, deferredEdges, id, incomingIds] ) const { connectedNodeIds, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, sourceSignature, } = signatures const lastRunSourceSignature = data?.lastRunSourceSignature const [renderedContent, setRenderedContent] = useState( () => (data?.cachedRenderedContent as string | undefined) ?? null ) const [resolvedContent, setResolvedContent] = useState( () => (data?.cachedResolvedContent as string | undefined) ?? null ) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const [streamingMarkdown, setStreamingMarkdown] = useState(null) const [streamingPreviewHtml, setStreamingPreviewHtml] = useState('') const [reasoningContent, setReasoningContent] = useState( () => (data?.cachedReasoningContent as string | undefined) ?? '' ) const [reasoningHtml, setReasoningHtml] = useState('') const [retryCount, setRetryCountState] = useState(0) const runIdRef = useRef(0) const loadingStartedAtRef = useRef(null) const minLoadingTimeoutRef = useRef | null>(null) const lastManualRunTriggerRef = useRef(0) const manualRunTriggerSyncedRef = useRef(false) const updateDataRef = useRef(updateData) updateDataRef.current = updateData const setNodesRef = useRef(setNodes) setNodesRef.current = setNodes const aiConnectionRef = useRef(aiConnection) aiConnectionRef.current = aiConnection const hasPendingInputs = effectiveUpdateMode === 'manual' && !loading && incomingIds.length > 0 && sourceSignature !== lastRunSourceSignature const lifecycle = useMemo( () => ({ updating: loading, error: error != null }), [loading, error] ) useSyncConnectionStatus(id, lifecycle) useEffect(() => { if (incomingIds.length === 0) { setRenderedContent(null) setResolvedContent(null) setError(null) setLoading(false) return } if (!srcId || !srcNode) { setRenderedContent(null) setResolvedContent(null) setError(null) setLoading(false) return } const logic = getSourceRenderingLogic(srcNode.type as string) if (!logic) { setRenderedContent(null) setResolvedContent(null) setError({ kind: 'render', message: `Unsupported source type: ${srcNode.type}` }) setLoading(false) return } if (effectiveUpdateMode === 'manual') { if (!manualRunTriggerSyncedRef.current) { lastManualRunTriggerRef.current = runTrigger manualRunTriggerSyncedRef.current = true return } if (runTrigger === 0) { const hasCachedOutput = Boolean( (data?.cachedRenderedContent ?? data?.cachedResolvedContent) as string | undefined ) if (!hasCachedOutput) { setRenderedContent(null) setResolvedContent(null) setError({ kind: 'no-content', message: 'Click Run to render.' }) setLoading(false) } return } if (runTrigger === lastManualRunTriggerRef.current) return lastManualRunTriggerRef.current = runTrigger } runIdRef.current += 1 const thisRunId = runIdRef.current const signatureForThisRun = sourceSignature const isManualMode = effectiveUpdateMode === 'manual' let cancelled = false const run = async () => { dispatchCanvasCommand({ type: 'path/addTrigger', payload: id }) loadingStartedAtRef.current = Date.now() setLoading(true) setError(null) setRenderedContent(null) setResolvedContent(null) setStreamingMarkdown(null) setReasoningContent('') updateDataRef.current({ cachedRenderedContent: undefined, cachedResolvedContent: undefined, cachedReasoningContent: undefined, }) try { // Pipeline: 1) Resolve (source) → 2) Render (output type) → 3) Cache const { nodes: ctxNodes, edges: ctxEdges } = nodesEdgesRef.current const context = { nodes: ctxNodes, edges: ctxEdges, sourceNodeId: srcId, renderNodeId: id, viewportWidth, viewportHeight, setNodes: setNodesRef.current ?? undefined, aiConnection: aiConnectionRef.current, ...(isAgentSource && { onStreamingStart: () => setStreamingMarkdown(''), onStreamingChunk: (chunk: string) => setStreamingMarkdown((prev) => (prev ?? '') + chunk), }), } as SourceRenderingLogicContext const { resolved, outputTypeId, reasoning } = await logic.getResolvedContent(context) if (thisRunId !== runIdRef.current) return setResolvedContent(resolved) setReasoningContent(reasoning ?? '') const typeRenderer = getConfigType(outputTypeId) const renderOptions = outputTypeId === 'wireframe' ? { width: viewportWidth, height: viewportHeight } : undefined const htmlOrSvg = await typeRenderer.render(resolved, renderOptions) if (thisRunId !== runIdRef.current) return setRenderedContent(htmlOrSvg) setError(null) updateDataRef.current({ cachedRenderedContent: htmlOrSvg, cachedResolvedContent: resolved, cachedReasoningContent: reasoning ?? '', ...(isManualMode ? { lastRunSourceSignature: signatureForThisRun } : {}), }) } catch (err: unknown) { if (!cancelled && thisRunId === runIdRef.current) { setRenderedContent(null) setReasoningContent('') setError({ kind: 'render', message: (err as { message?: string })?.message ?? 'Render error', }) } } finally { setStreamingMarkdown(null) if (thisRunId === runIdRef.current) { const startedAt = loadingStartedAtRef.current ?? 0 const elapsed = Date.now() - startedAt const remaining = Math.max(0, 1000 - elapsed) if (remaining > 0) { minLoadingTimeoutRef.current = setTimeout(() => { minLoadingTimeoutRef.current = null if (thisRunId === runIdRef.current) setLoading(false) }, remaining) } else { setLoading(false) } } } } if (effectiveUpdateMode === 'auto') { const debounceTimer = setTimeout(run, RENDER_DEBOUNCE_MS) return () => { cancelled = true clearTimeout(debounceTimer) if (minLoadingTimeoutRef.current != null) { clearTimeout(minLoadingTimeoutRef.current) minLoadingTimeoutRef.current = null } setLoading(false) } } run() return () => { cancelled = true setStreamingMarkdown(null) if (minLoadingTimeoutRef.current != null) { clearTimeout(minLoadingTimeoutRef.current) minLoadingTimeoutRef.current = null } } // Content updates only when connected-node data changes (sourceSignature) or explicit run/viewport. // React Flow updates (position, selection, context ref churn) do not trigger re-runs. }, [ id, srcId, srcNode?.type, effectiveUpdateMode, runTrigger, sourceSignature, viewportWidth, viewportHeight, retryCount, incomingIds.length, ]) useEffect(() => { if (streamingMarkdown === null) { setStreamingPreviewHtml('') return } let cancelled = false import('marked') .then(async ({ marked }) => { if (cancelled) return const parsed = typeof marked.parse === 'function' ? await (marked.parse as (s: string) => Promise)(streamingMarkdown) : (marked as (s: string) => string)(streamingMarkdown) const str = typeof parsed === 'string' ? parsed : String(parsed) if (!cancelled) setStreamingPreviewHtml(str) }) .catch(() => { if (!cancelled) setStreamingPreviewHtml(streamingMarkdown) }) return () => { cancelled = true } }, [streamingMarkdown]) useEffect(() => { if (!reasoningContent) { setReasoningHtml('') return } let cancelled = false import('marked') .then(async ({ marked }) => { if (cancelled) return const parsed = typeof marked.parse === 'function' ? await (marked.parse as (s: string) => Promise)(reasoningContent) : (marked as (s: string) => string)(reasoningContent) const str = typeof parsed === 'string' ? parsed : String(parsed) if (!cancelled) setReasoningHtml(str) }) .catch(() => { if (!cancelled) setReasoningHtml(reasoningContent) }) return () => { cancelled = true } }, [reasoningContent]) const isSvgOutput = Boolean( renderedContent?.trim() && /]/i.test(renderedContent.trim()) ) const renderedThinkSplit = useMemo(() => { if (outputType === 'image' || !renderedContent) return { main: '', think: '' } return parseThinkSections(renderedContent) }, [renderedContent, outputType]) const streamingThinkSplit = useMemo(() => { if (streamingMarkdown === null) return { main: '', think: '' } const src = streamingPreviewHtml || streamingMarkdown return parseThinkSections(src) }, [streamingMarkdown, streamingPreviewHtml]) const rawDisplayContent = useMemo(() => { const src = resolvedContent != null ? resolvedContent : streamingMarkdown ?? '' return stripTemplateSyntax(src) }, [resolvedContent, streamingMarkdown]) const displayContent = useMemo(() => { if (!renderedContent || !isSvgOutput) return renderedContent return processSvgDisplay(renderedContent) }, [renderedContent, isSvgOutput]) const incrementRunTrigger = useCallback(() => { updateData({ runTrigger: (data?.runTrigger ?? 0) + 1 }) }, [data?.runTrigger, updateData]) const setUpdateMode = useCallback( (mode: 'auto' | 'manual') => { updateData({ updateMode: mode }) }, [updateData] ) const setRetryCount = useCallback((fn: (c: number) => number) => { setRetryCountState(fn) }, []) const emptyStateAction = useMemo(() => { if (incomingIds.length > 0) return null if (!setNodes || !setEdges) return null return { label: 'Create Config', onClick: () => { const nid = getNextNodeId( 'config', (nodes as { id: string }[]).map((n) => n.id) ) const thisNode = (nodes as { id: string; position?: { x: number; y: number } }[]).find( (n) => n.id === id ) const pos = thisNode?.position ?? { x: 0, y: 0 } const newPos = { x: pos.x - 220, y: pos.y } const newNode = { id: nid, type: 'config', position: newPos, data: getDefaultDataForType('config', nid), style: getDefaultStyle('config'), } setNodes((nds: unknown[]) => nds.concat(newNode) as unknown[]) setEdges((eds: unknown[]) => eds.concat({ id: `e-${nid}-${id}`, source: nid, target: id }) as unknown[] ) }, } }, [id, incomingIds.length, nodes, setNodes, setEdges]) const sourceData = useMemo(() => (srcNode?.data as Record) ?? {}, [srcNode?.data]) const sourceNodeType = (srcNode?.type as string) ?? null const displayStatus = useMemo( () => getNodeDisplayStatus({ loading, error, hasContent: !!renderedContent }), [loading, error, renderedContent] ) return { displayStatus, lifecycle, incomingIds, effectiveUpdateMode, runTrigger, hasPendingInputs, loading, error, incrementRunTrigger, setRetryCount, renderedContent, resolvedContent, streamingMarkdown, streamingPreviewHtml, reasoningContent, reasoningHtml, outputType, outputLabel: configType.label, rawLanguage: configTypeId, isSvgOutput, renderedThinkSplit, streamingThinkSplit, rawDisplayContent, displayContent, viewportWidth, viewportHeight, setUpdateMode, emptyStateAction, sourceData, sourceNodeType, } }