nit
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user