@@ -10,7 +10,6 @@ import {
1010} from "@xyflow/react" ;
1111import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
1212import "@xyflow/react/dist/style.css" ;
13- import ELK from "elkjs/lib/elk.bundled.js" ;
1413import { twJoin } from "tailwind-merge" ;
1514import { useShallow } from "zustand/react/shallow" ;
1615import { useConversationTree } from "../tree/useConversationTree" ;
@@ -22,14 +21,12 @@ interface DiagramViewProps {
2221 onDuplicateFromNode ?: ( nodeId : string ) => void ;
2322}
2423
25- const elk = new ELK ( ) ;
26-
2724const boxSize = {
2825 width : 320 ,
2926 height : 80 ,
3027} ;
3128
32- const columnGap = 64 ;
29+ const columnGap = 80 ;
3330const rowGap = 80 ;
3431
3532const nodeStyleBase = {
@@ -202,121 +199,115 @@ const DiagramView = ({
202199 ) ;
203200
204201 useEffect ( ( ) => {
205- const children = Object . values ( treeNodes ) . map ( ( node ) => ( {
206- id : node . id ,
207- width : boxSize . width ,
208- height : boxSize . height ,
209- } ) ) ;
210- const edges = Object . values ( treeEdges ) . map ( ( edge ) => ( {
211- id : edge . id ,
212- sources : [ edge . from ] ,
213- targets : [ edge . to ] ,
214- } ) ) ;
215-
216- let cancelled = false ;
217- void elk
218- . layout ( {
219- id : "root" ,
220- layoutOptions : {
221- "elk.algorithm" : "layered" ,
222- "elk.direction" : "DOWN" ,
223- "elk.layered.nodePlacement.strategy" : "BRANDES_KOEPF" ,
224- "elk.spacing.nodeNode" : "40" ,
225- "elk.layered.spacing.nodeNodeBetweenLayers" : "80" ,
226- "elk.edgeRouting" : "ORTHOGONAL" ,
202+ // No conversation = no graph
203+ const internalNodes = Object . values ( treeNodes ) ;
204+ if ( internalNodes . length === 0 ) {
205+ setLayoutNodes ( [ ] ) ;
206+ setLayoutEdges ( [ ] ) ;
207+ return ;
208+ }
209+
210+ const laneById = new Map ( laneAssignments ) ;
211+ let nextLane = laneAssignments . size ;
212+ for ( const lane of laneAssignments . values ( ) ) {
213+ nextLane = Math . max ( nextLane , lane + 1 ) ;
214+ }
215+
216+ const getLane = ( nodeId : string ) : number => {
217+ const lane = laneById . get ( nodeId ) ;
218+ if ( lane !== undefined ) {
219+ return lane ;
220+ }
221+ // @ts -expect-error rsbuild v0.5.2 doesn't support env index access or import.meta.env
222+ if ( process . env . NODE_ENV !== "production" ) {
223+ // eslint-disable-next-line no-console
224+ console . warn ( `lane missing for node ${ nodeId } ; allocating new lane` ) ;
225+ }
226+ const assigned = nextLane ;
227+ nextLane += 1 ;
228+ laneById . set ( nodeId , assigned ) ;
229+ return assigned ;
230+ } ;
231+
232+ const laneLookup = new Map < string , number > ( ) ;
233+ for ( const node of internalNodes ) {
234+ laneLookup . set ( node . id , getLane ( node . id ) ) ;
235+ }
236+
237+ // Sort by (depth, lane, createdAt) for stable ordering
238+ const sortedNodes = [ ...internalNodes ] . sort ( ( a , b ) => {
239+ const depthA = depthByNode . get ( a . id ) ?? 0 ;
240+ const depthB = depthByNode . get ( b . id ) ?? 0 ;
241+ if ( depthA !== depthB ) {
242+ return depthA - depthB ;
243+ }
244+
245+ const laneA = laneLookup . get ( a . id ) ?? 0 ;
246+ const laneB = laneLookup . get ( b . id ) ?? 0 ;
247+ if ( laneA !== laneB ) {
248+ return laneA - laneB ;
249+ }
250+
251+ const createdDiff = a . createdAt - b . createdAt ;
252+ if ( createdDiff !== 0 ) {
253+ return createdDiff ;
254+ }
255+
256+ return a . id . localeCompare ( b . id ) ;
257+ } ) ;
258+
259+ const nodes : Node [ ] = sortedNodes . map ( ( dataNode ) => {
260+ const label = (
261+ < div className = "flex items-start gap-2" >
262+ < div className = "min-w-0" >
263+ < p className = "text-xs font-mono uppercase text-slate-500" >
264+ { dataNode . role }
265+ </ p >
266+ < p className = "mt-1 text-sm font-medium leading-snug text-slate-800 line-clamp-3" >
267+ { dataNode . text }
268+ </ p >
269+ </ div >
270+ </ div >
271+ ) ;
272+
273+ const isActive = activePathIds . nodes . has ( dataNode . id ) ;
274+ const style = {
275+ ...nodeStyleBase ,
276+ border : isActive ? "2px solid #2563eb" : "1px solid #e2e8f0" ,
277+ opacity : isActive ? 1 : 0.7 ,
278+ } ;
279+
280+ const lane = laneLookup . get ( dataNode . id ) ?? 0 ;
281+ const depth = depthByNode . get ( dataNode . id ) ?? 0 ;
282+
283+ return {
284+ id : dataNode . id ,
285+ position : {
286+ x : lane * ( boxSize . width + columnGap ) ,
287+ y : depth * ( boxSize . height + rowGap ) ,
227288 } ,
228- children,
229- edges,
230- } )
231- . then (
232- ( result : {
233- children ?: Array < { id : string ; x ?: number ; y ?: number } > ;
234- edges ?: Array < { id : string ; sources ?: string [ ] ; targets ?: string [ ] } > ;
235- } ) => {
236- if ( cancelled ) {
237- return ;
238- }
239- const nodes : Node [ ] = ( result . children ?? [ ] ) . map ( ( child ) => {
240- const dataNode = treeNodes [ child . id ] ;
241- if ( ! dataNode ) {
242- return {
243- id : child . id ,
244- position : { x : child . x ?? 0 , y : child . y ?? 0 } ,
245- data : { label : child . id } ,
246- } satisfies Node ;
247- }
248-
249- const label = (
250- < div className = "flex items-start gap-2" >
251- < div className = "min-w-0" >
252- < p className = "text-xs font-mono uppercase text-slate-500" >
253- { dataNode . role }
254- </ p >
255- < p className = "mt-1 text-sm font-medium leading-snug text-slate-800 line-clamp-3" >
256- { dataNode . text }
257- </ p >
258- </ div >
259- </ div >
260- ) ;
261-
262- const isActive = activePathIds . nodes . has ( child . id ) ;
263- const style = {
264- ...nodeStyleBase ,
265- border : isActive ? "2px solid #2563eb" : "1px solid #e2e8f0" ,
266- opacity : isActive ? 1 : 0.7 ,
267- } ;
268-
269- return {
270- id : child . id ,
271- position : {
272- x :
273- ( laneAssignments . get ( child . id ) ?? laneAssignments . size ) *
274- ( boxSize . width + columnGap ) ,
275- y :
276- child . y ??
277- ( depthByNode . get ( child . id ) ?? 0 ) * ( boxSize . height + rowGap ) ,
278- } ,
279- data : { label } ,
280- style,
281- } satisfies Node ;
282- } ) ;
283-
284- const edgesStyled : Edge [ ] = ( result . edges ?? [ ] ) . flatMap ( ( edge ) => {
285- const source = edge . sources ?. [ 0 ] ;
286- const target = edge . targets ?. [ 0 ] ;
287- if ( ! source || ! target ) {
288- return [ ] ;
289- }
290- const isActive = activePathIds . edges . has ( edge . id ) ;
291- return [
292- {
293- id : edge . id ,
294- source,
295- target,
296- type : "smoothstep" ,
297- animated : false ,
298- style : {
299- strokeWidth : isActive ? 2 : 1.5 ,
300- stroke : isActive ? "#2563eb" : "#94a3b8" ,
301- } ,
302- } as Edge ,
303- ] ;
304- } ) ;
305-
306- setLayoutNodes ( nodes ) ;
307- setLayoutEdges ( edgesStyled ) ;
289+ data : { label } ,
290+ style,
291+ } satisfies Node ;
292+ } ) ;
293+
294+ const edgesStyled : Edge [ ] = Object . values ( treeEdges ) . map ( ( edge ) => {
295+ const isActive = activePathIds . edges . has ( edge . id ) ;
296+ return {
297+ id : edge . id ,
298+ source : edge . from ,
299+ target : edge . to ,
300+ type : "smoothstep" ,
301+ animated : false ,
302+ style : {
303+ strokeWidth : isActive ? 2 : 1.5 ,
304+ stroke : isActive ? "#2563eb" : "#94a3b8" ,
308305 } ,
309- )
310- . catch ( ( ) => {
311- if ( ! cancelled ) {
312- setLayoutNodes ( [ ] ) ;
313- setLayoutEdges ( [ ] ) ;
314- }
315- } ) ;
306+ } satisfies Edge ;
307+ } ) ;
316308
317- return ( ) => {
318- cancelled = true ;
319- } ;
309+ setLayoutNodes ( nodes ) ;
310+ setLayoutEdges ( edgesStyled ) ;
320311 } , [
321312 treeNodes ,
322313 treeEdges ,
0 commit comments