This commit is contained in:
2026-03-13 00:19:14 +01:00
parent 2ff7e1f5f2
commit 2d8f13aebb
5 changed files with 58 additions and 39 deletions

View File

@@ -2,7 +2,7 @@
* Route wrapper for the canvas: resolves projectId from URL and updates lastEditedAt on open. * Route wrapper for the canvas: resolves projectId from URL and updates lastEditedAt on open.
*/ */
import React, { useEffect } from 'react' import React, { useEffect, useRef } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { CanvasPage } from './CanvasPage' import { CanvasPage } from './CanvasPage'
import { usePlatform } from '@/app/kosmos/KosmosContext' import { usePlatform } from '@/app/kosmos/KosmosContext'
@@ -10,12 +10,14 @@ import { usePlatform } from '@/app/kosmos/KosmosContext'
export function CanvasRoute() { export function CanvasRoute() {
const { projectId } = useParams<{ projectId: string }>() const { projectId } = useParams<{ projectId: string }>()
const { projects, updateLastEdited } = usePlatform() const { projects, updateLastEdited } = usePlatform()
const updateLastEditedRef = useRef(updateLastEdited)
updateLastEditedRef.current = updateLastEdited
const project = projects.find((p) => p.id === projectId) const project = projects.find((p) => p.id === projectId)
useEffect(() => { useEffect(() => {
if (projectId) updateLastEdited(projectId) if (projectId) updateLastEditedRef.current(projectId)
}, [projectId, updateLastEdited]) }, [projectId])
if (!projectId) return null if (!projectId) return null
if (!project) { if (!project) {

View File

@@ -4,7 +4,7 @@
* Wherever this editor is used, it provides the same features (syntax highlighting + Nunjucks). * Wherever this editor is used, it provides the same features (syntax highlighting + Nunjucks).
* The parent (node) provides the expected base language for highlighting. * The parent (node) provides the expected base language for highlighting.
*/ */
import React, { useCallback, useRef } from 'react' import React, { useCallback, useLayoutEffect, useRef } from 'react'
import Editor from 'react-simple-code-editor' import Editor from 'react-simple-code-editor'
import { highlight, type HighlightLanguage } from '@/lib/syntaxHighlight' import { highlight, type HighlightLanguage } from '@/lib/syntaxHighlight'
@@ -53,6 +53,7 @@ export function CodeEditor({
minHeight = 120, minHeight = 120,
}: CodeEditorProps) { }: CodeEditorProps) {
const containerRef = useRef<HTMLDivElement | null>(null) const containerRef = useRef<HTMLDivElement | null>(null)
const selectionRestoreRef = useRef<{ start: number; end: number } | null>(null)
const highlightCode = useCallback( const highlightCode = useCallback(
(code: string) => highlight(code, language), (code: string) => highlight(code, language),
@@ -69,17 +70,27 @@ export function CodeEditor({
(textareaId ? document.getElementById(textareaId) : containerRef.current?.querySelector('textarea')) as HTMLTextAreaElement | null (textareaId ? document.getElementById(textareaId) : containerRef.current?.querySelector('textarea')) as HTMLTextAreaElement | null
const start = ta?.selectionStart ?? newValue.length const start = ta?.selectionStart ?? newValue.length
const end = ta?.selectionEnd ?? newValue.length const end = ta?.selectionEnd ?? newValue.length
selectionRestoreRef.current = { start, end }
onValueChange(newValue) onValueChange(newValue)
requestAnimationFrame(() => {
const el = (textareaId
? document.getElementById(textareaId)
: containerRef.current?.querySelector('textarea')) as HTMLTextAreaElement | null
if (el) el.setSelectionRange(start, end)
})
}, },
[onValueChange, readOnly, textareaId] [onValueChange, readOnly, textareaId]
) )
useLayoutEffect(() => {
const pending = selectionRestoreRef.current
if (pending == null) return
selectionRestoreRef.current = null
const el = (textareaId
? document.getElementById(textareaId)
: containerRef.current?.querySelector('textarea')) as HTMLTextAreaElement | null
if (el) {
const { start, end } = pending
const safeEnd = Math.min(end, el.value.length)
const safeStart = Math.min(start, safeEnd)
el.setSelectionRange(safeStart, safeEnd)
}
}, [value, textareaId])
return ( return (
<div ref={containerRef} className="code-editor" style={{ minHeight: 0 }}> <div ref={containerRef} className="code-editor" style={{ minHeight: 0 }}>
<Editor <Editor

View File

@@ -190,7 +190,16 @@ export function useRenderingNodeState(
const manualRunTriggerSyncedRef = useRef(false) const manualRunTriggerSyncedRef = useRef(false)
const pathCtx = useContext(ConnectionPathContext) const pathCtx = useContext(ConnectionPathContext)
const pathCtxRef = useRef(pathCtx)
pathCtxRef.current = pathCtx
const triggerNodeIds = pathCtx?.connectionPathTriggerNodeIds ?? [] const triggerNodeIds = pathCtx?.connectionPathTriggerNodeIds ?? []
const updateDataRef = useRef(updateData)
updateDataRef.current = updateData
const setNodesRef = useRef(setNodes)
setNodesRef.current = setNodes
const aiConnectionRef = useRef(aiConnection)
aiConnectionRef.current = aiConnection
const hasPendingInputs = const hasPendingInputs =
effectiveUpdateMode === 'manual' && effectiveUpdateMode === 'manual' &&
!loading && !loading &&
@@ -256,6 +265,7 @@ export function useRenderingNodeState(
let cancelled = false let cancelled = false
const run = async () => { const run = async () => {
pathCtxRef.current?.addConnectionPathTrigger?.(id)
loadingStartedAtRef.current = Date.now() loadingStartedAtRef.current = Date.now()
setLoading(true) setLoading(true)
setError(null) setError(null)
@@ -263,7 +273,7 @@ export function useRenderingNodeState(
setResolvedContent(null) setResolvedContent(null)
setStreamingMarkdown(null) setStreamingMarkdown(null)
setReasoningContent('') setReasoningContent('')
updateData({ updateDataRef.current({
cachedRenderedContent: undefined, cachedRenderedContent: undefined,
cachedResolvedContent: undefined, cachedResolvedContent: undefined,
cachedReasoningContent: undefined, cachedReasoningContent: undefined,
@@ -278,8 +288,8 @@ export function useRenderingNodeState(
renderNodeId: id, renderNodeId: id,
viewportWidth, viewportWidth,
viewportHeight, viewportHeight,
setNodes: setNodes ?? undefined, setNodes: setNodesRef.current ?? undefined,
aiConnection, aiConnection: aiConnectionRef.current,
...(isAgentSource && { ...(isAgentSource && {
onStreamingStart: () => setStreamingMarkdown(''), onStreamingStart: () => setStreamingMarkdown(''),
onStreamingChunk: (chunk: string) => onStreamingChunk: (chunk: string) =>
@@ -297,7 +307,7 @@ export function useRenderingNodeState(
if (thisRunId !== runIdRef.current) return if (thisRunId !== runIdRef.current) return
setRenderedContent(htmlOrSvg) setRenderedContent(htmlOrSvg)
setError(null) setError(null)
updateData({ updateDataRef.current({
cachedRenderedContent: htmlOrSvg, cachedRenderedContent: htmlOrSvg,
cachedResolvedContent: resolved, cachedResolvedContent: resolved,
cachedReasoningContent: reasoning ?? '', cachedReasoningContent: reasoning ?? '',
@@ -351,26 +361,18 @@ export function useRenderingNodeState(
minLoadingTimeoutRef.current = null 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, id,
srcId, srcId,
srcNode?.type, srcNode?.type,
effectiveUpdateMode, effectiveUpdateMode,
runTrigger, runTrigger,
sourceContent,
sourceSignature, sourceSignature,
configSignature,
edgesSignature,
variablesSignature,
functionsSignature,
dataSignature,
viewportWidth, viewportWidth,
viewportHeight, viewportHeight,
retryCount, retryCount,
updateData,
setNodes,
aiConnection,
isAgentSource,
incomingIds.length, incomingIds.length,
]) ])

View File

@@ -4,7 +4,7 @@
* - **AbstractNodeProps<TData>** — Typed props (id, data, width?, height?, selected?) for your node. * - **AbstractNodeProps<TData>** — Typed props (id, data, width?, height?, selected?) for your node.
* - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges, * - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges,
* updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds. Calling updateData() * updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds. Calling updateData()
* also reports this node as a trigger for connection path (lifecycle "trigger"). * also marks this node as a connection-path trigger so edges update on data changes.
* - **createAbstractNodeComponent(displayName, Component)** — Wraps with memo + nodePropsAreEqual. * - **createAbstractNodeComponent(displayName, Component)** — Wraps with memo + nodePropsAreEqual.
* *
* **Node lifecycle / connection status:** Nodes that can be updating, paused, or in error should * **Node lifecycle / connection status:** Nodes that can be updating, paused, or in error should
@@ -15,7 +15,7 @@
* component, then export const MyNode = createAbstractNodeComponent('MyNode', MyNodeComponent). * component, then export const MyNode = createAbstractNodeComponent('MyNode', MyNodeComponent).
*/ */
import React, { useCallback, useContext, useMemo } from 'react' import React, { useCallback, useContext, useMemo, useRef } from 'react'
import { GraphContext, ConnectionPathContext } from './flowContext' import { GraphContext, ConnectionPathContext } from './flowContext'
import { nodePropsAreEqual } from './flowUtils' import { nodePropsAreEqual } from './flowUtils'
import type { AppNode } from './nodeTypes' import type { AppNode } from './nodeTypes'
@@ -82,12 +82,13 @@ export function useAbstractNode<TData = Record<string, unknown>>(
): AbstractNodeContext<TData> { ): AbstractNodeContext<TData> {
const graphCtx = useContext(GraphContext) const graphCtx = useContext(GraphContext)
const pathCtx = useContext(ConnectionPathContext) const pathCtx = useContext(ConnectionPathContext)
const addTriggerRef = useRef(pathCtx?.addConnectionPathTrigger)
addTriggerRef.current = pathCtx?.addConnectionPathTrigger
const nodes = graphCtx?.graphRef?.current?.nodes ?? [] const nodes = graphCtx?.graphRef?.current?.nodes ?? []
const edges = graphCtx?.edges ?? [] const edges = graphCtx?.edges ?? []
const setNodes = graphCtx?.setNodes const setNodes = graphCtx?.setNodes
const setEdges = graphCtx?.setEdges const setEdges = graphCtx?.setEdges
const addConnectionPathTrigger = pathCtx?.addConnectionPathTrigger
const updateData = useCallback( const updateData = useCallback(
(partial: Partial<TData>) => { (partial: Partial<TData>) => {
if (!setNodes) return if (!setNodes) return
@@ -96,9 +97,9 @@ export function useAbstractNode<TData = Record<string, unknown>>(
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
) as AppNode[] ) as AppNode[]
) )
addConnectionPathTrigger?.(id) addTriggerRef.current?.(id)
}, },
[id, setNodes, addConnectionPathTrigger] [id, setNodes]
) )
const incomingEdges = useMemo( const incomingEdges = useMemo(

View File

@@ -54,36 +54,39 @@ export function useSyncConnectionStatus(
state: NodeConnectionStatusState state: NodeConnectionStatusState
): void { ): void {
const ctx = useContext(ConnectionPathContext) const ctx = useContext(ConnectionPathContext)
const ctxRef = useRef(ctx)
ctxRef.current = ctx
const { updating, error, paused } = state const { updating, error, paused } = state
const prevRef = useRef({ updating: false, error: false, paused: false }) const prevRef = useRef({ updating: false, error: false, paused: false })
useEffect(() => { useEffect(() => {
const pathCtx = ctxRef.current
const prev = prevRef.current const prev = prevRef.current
const nowUpdating = Boolean(updating) const nowUpdating = Boolean(updating)
const nowError = Boolean(error) const nowError = Boolean(error)
const nowPaused = Boolean(paused) const nowPaused = Boolean(paused)
if (prev.updating !== nowUpdating) { if (prev.updating !== nowUpdating) {
if (nowUpdating) ctx?.startConnectionPathUpdate?.(nodeId) if (nowUpdating) pathCtx?.startConnectionPathUpdate?.(nodeId)
else ctx?.endConnectionPathUpdate?.(nodeId) else pathCtx?.endConnectionPathUpdate?.(nodeId)
prev.updating = nowUpdating prev.updating = nowUpdating
} }
if (prev.error !== nowError) { if (prev.error !== nowError) {
if (nowError) ctx?.addConnectionPathError?.(nodeId) if (nowError) pathCtx?.addConnectionPathError?.(nodeId)
else ctx?.removeConnectionPathError?.(nodeId) else pathCtx?.removeConnectionPathError?.(nodeId)
prev.error = nowError prev.error = nowError
} }
if (prev.paused !== nowPaused) { if (prev.paused !== nowPaused) {
if (nowPaused) ctx?.addConnectionPathPausedNode?.(nodeId) if (nowPaused) pathCtx?.addConnectionPathPausedNode?.(nodeId)
else ctx?.removeConnectionPathPausedNode?.(nodeId) else pathCtx?.removeConnectionPathPausedNode?.(nodeId)
prev.paused = nowPaused prev.paused = nowPaused
} }
return () => { return () => {
if (prevRef.current.updating) ctx?.endConnectionPathUpdate?.(nodeId) if (prevRef.current.updating) pathCtx?.endConnectionPathUpdate?.(nodeId)
if (prevRef.current.error) ctx?.removeConnectionPathError?.(nodeId) if (prevRef.current.error) pathCtx?.removeConnectionPathError?.(nodeId)
if (prevRef.current.paused) ctx?.removeConnectionPathPausedNode?.(nodeId) if (prevRef.current.paused) pathCtx?.removeConnectionPathPausedNode?.(nodeId)
prevRef.current = { updating: false, error: false, paused: false } prevRef.current = { updating: false, error: false, paused: false }
} }
}, [nodeId, updating, error, paused, ctx]) }, [nodeId, updating, error, paused])
} }