@@ -63,9 +63,12 @@ type TabId = "markings" | "workitems" | "occurrences" | "telemetry" | "debug"
6363//
6464// Marking + enabled-transition state commits at TWO boundaries:
6565// * inputEndMs (drain visible): displayed diagram flips to intermediate
66- // (input places drained, output places still pre-fire). Intermediate
67- // enabled set computed client-side (T enabled iff every input place of T
68- // still has ≥1 token).
66+ // (input places drained, output places still pre-fire). The displayed
67+ // ENABLED SET stays pinned to pre-fire — computing engine-correct
68+ // enablement client-side would require arc-multiplicity, guards, and
69+ // binding-expression evaluation, which the wire does not carry. Pre-fire
70+ // is a known-good approximation that never lies about specific
71+ // transitions during the brief dwell.
6972// * fullMs (arrival visible): displayed diagram flips to post-fire
7073// (server-pushed). Post-fire enabled set from `:replay_to_version` reply
7174// in replay mode, or from the post-fire diagram's `enabled_count` in
@@ -112,24 +115,16 @@ function buildIntermediateDiagram(
112115 return { ...post , places }
113116}
114117
115- // Client-side intermediate enabled set: T is enabled iff every p_to_t arc's
116- // place has ≥1 token in the supplied diagram. Matches plan option (b) — no
117- // wire change; the brief intermediate-phase glow approximates engine truth.
118- function computeEnabledFromDiagramTokens ( diagram : DiagramPayload ) : ReadonlySet < string > {
119- const tokens = new Map < string , number > ( )
120- for ( const p of diagram . places ) tokens . set ( p . name , p . tokens_count )
121- const inputs = new Map < string , string [ ] > ( )
122- for ( const arc of diagram . arcs ) {
123- if ( arc . orientation !== "p_to_t" ) continue
124- const list = inputs . get ( arc . transition ) ?? [ ]
125- list . push ( arc . place )
126- inputs . set ( arc . transition , list )
127- }
118+ // Pre-fire enabled set in LIVE mode is derived directly from the pre-fire
119+ // diagram's per-transition `enabled_count`. Used as the override during the
120+ // firing animation so the highlight doesn't flip to the post-fire set the
121+ // instant the server pushes the new diagram. Replay mode does NOT need this
122+ // helper — `replayEnabledTransitions` already holds the pre-fire value
123+ // through Phases A+B and snaps to post-fire at applyFinalPendings().
124+ function preFireEnabledFromDiagram ( diagram : DiagramPayload ) : ReadonlySet < string > {
128125 const enabled = new Set < string > ( )
129126 for ( const t of diagram . transitions ) {
130- const inputList = inputs . get ( t . name )
131- if ( ! inputList || inputList . length === 0 ) continue
132- if ( inputList . every ( ( p ) => ( tokens . get ( p ) ?? 0 ) >= 1 ) ) enabled . add ( t . name )
127+ if ( t . enabled_count > 0 ) enabled . add ( t . name )
133128 }
134129 return enabled
135130}
@@ -492,7 +487,8 @@ function DetailContent({
492487
493488 // Three-phase intermediate state. Computed once per (firingPhase,
494489 // firingPreDiagram, diagram) tuple. `firingPhase === null` short-circuits so
495- // off-firing renders pay nothing.
490+ // off-firing renders pay nothing. Enabled-transition set during firing is
491+ // NOT computed from this diagram — see `enabledTransitionsOverride` below.
496492 const intermediateState = useMemo ( ( ) => {
497493 if ( firingPhase === null || firingPreDiagram === null || diagram === null ) {
498494 return null
@@ -504,11 +500,20 @@ function DetailContent({
504500 )
505501 return {
506502 diagram : interDiag ,
507- markings : diagramToMarkingRows ( interDiag ) ,
508- enabled : computeEnabledFromDiagramTokens ( interDiag )
503+ markings : diagramToMarkingRows ( interDiag )
509504 }
510505 } , [ firingPhase , firingPreDiagram , diagram ] )
511506
507+ // Live-mode pre-fire enabled set. Captured from the pre-fire diagram so the
508+ // override stays stable through the entire firing animation (Phase A +
509+ // intermediate dwell + Phase B), instead of letting NetDiagram derive from
510+ // `displayedDiagram.transitions[].enabled_count` — which flips to post-fire
511+ // values the instant the server pushes the post-fire diagram.
512+ const liveFiringEnabledOverride = useMemo ( ( ) => {
513+ if ( firingPhase === null || firingPreDiagram === null ) return null
514+ return preFireEnabledFromDiagram ( firingPreDiagram )
515+ } , [ firingPhase , firingPreDiagram ] )
516+
512517 // Three-phase selectors. RAF ticks update firingProgress so these recompute
513518 // on every frame the phase advances; identity changes only at the boundaries
514519 // (pre→intermediate at inputEndMs, intermediate→post at fullMs). The dwell
@@ -559,6 +564,11 @@ function DetailContent({
559564 if ( prev === null ) return
560565 if ( activeVersion <= prev ) return
561566 if ( replayState !== null ) return
567+ // Multi-step bumps (runner batched N>1 occurrences into one bridge event)
568+ // skip the firing animation. Animating only the LAST occurrence over the
569+ // already-combined post-fire diagram would drop the intermediate firings
570+ // from the visible timeline. Snap straight to post-fire instead.
571+ if ( activeVersion !== prev + 1 ) return
562572
563573 const occurrence = occurrences . find ( ( o ) => o . step_number === activeVersion )
564574 if ( ! occurrence ) return
@@ -615,22 +625,22 @@ function DetailContent({
615625 } , [ firingPhase , applyFinalPendings ] )
616626
617627 // Enabled-transition override resolution:
618- // * Phase B (intermediate): client-computed set against drained inputs.
619- // Drives BOTH live and replay glow during the brief intermediate window.
620- // * Replay (non-firing OR Phase A/post): replay reply's enabled set.
621- // `replayEnabledTransitions` holds the PRE-fire value through Phases A+B
622- // and snaps to post-fire when applyFinalPendings runs at fullMs.
623- // * Live (non-firing OR Phase A/post): undefined → NetDiagram derives from
624- // the displayedDiagram's transitions[].enabled_count. Pre-fire diagram
625- // carries pre-fire counts (Phase A), post-fire diagram carries post-fire
626- // counts (post).
628+ // * Firing (any phase before fullMs): pre-fire enabled set. In replay,
629+ // `replayEnabledTransitions` already holds the pre-fire value through
630+ // Phases A+B and snaps to post-fire when applyFinalPendings runs at
631+ // fullMs. In live, `liveFiringEnabledOverride` mirrors the pre-fire
632+ // diagram's `enabled_count`, so the highlight doesn't follow the
633+ // server-pushed post-fire diagram mid-animation. We deliberately do NOT
634+ // re-derive enablement client-side from the intermediate (drained)
635+ // marking — engine-correct enablement needs arc multiplicity, guards,
636+ // and binding-expression evaluation; the wire carries none of that.
637+ // * Replay, non-firing: replay reply's enabled set.
638+ // * Live, non-firing: undefined → NetDiagram derives from the displayed
639+ // diagram's transitions[].enabled_count.
627640 const enabledTransitionsOverride : ReadonlySet < string > | undefined = ( ( ) => {
628- if (
629- firingPhase !== null &&
630- firingDisplayPhase === "intermediate" &&
631- intermediateState
632- ) {
633- return intermediateState . enabled
641+ if ( firingPhase !== null && firingDisplayPhase !== "post" ) {
642+ if ( replayState !== null ) return replayEnabledTransitions
643+ if ( liveFiringEnabledOverride !== null ) return liveFiringEnabledOverride
634644 }
635645 return replayState === null ? undefined : replayEnabledTransitions
636646 } ) ( )
0 commit comments