@@ -4,10 +4,33 @@ import { parseHTML } from 'linkedom';
44import { act , createElement } from 'react' ;
55import { createRoot , type Root } from 'react-dom/client' ;
66import type { AgentGraphClientSnapshot } from '@maka/runtime/stream-graph-read-model' ;
7+ import type { AgentGraphEpochSummary } from '@maka/runtime-host/protocol' ;
78import { AgentGraphPanel } from '../../renderer/agent-graph-panel.js' ;
89
910type GraphListener = ( ) => void ;
1011
12+ interface DeferredRead {
13+ started : Promise < void > ;
14+ release ( ) : void ;
15+ }
16+
17+ interface DeferredReadGate extends DeferredRead {
18+ markStarted ( ) : void ;
19+ waitForRelease : Promise < void > ;
20+ }
21+
22+ function deferredReadGate ( ) : DeferredReadGate {
23+ let markStarted = ( ) => { } ;
24+ let release = ( ) => { } ;
25+ const started = new Promise < void > ( ( resolve ) => {
26+ markStarted = resolve ;
27+ } ) ;
28+ const waitForRelease = new Promise < void > ( ( resolve ) => {
29+ release = resolve ;
30+ } ) ;
31+ return { started, markStarted, release, waitForRelease } ;
32+ }
33+
1134const originalGlobals = {
1235 document : globalThis . document ,
1336 window : globalThis . window ,
@@ -59,11 +82,18 @@ function snapshot(
5982 } ;
6083}
6184
62- function installGraphRenderer ( initial : AgentGraphClientSnapshot ) : {
85+ function installGraphRenderer (
86+ initial : AgentGraphClientSnapshot ,
87+ historical : readonly AgentGraphClientSnapshot [ ] = [ ] ,
88+ ) : {
6389 container : Element ;
6490 root : Root ;
6591 setSnapshot ( next : AgentGraphClientSnapshot ) : Promise < void > ;
92+ evict ( graphId : string ) : void ;
6693 renderSession ( sessionId : string ) : Promise < void > ;
94+ holdNextEpochList ( sessionId : string ) : DeferredRead ;
95+ holdNextSnapshot ( graphId : string ) : DeferredRead ;
96+ stopCalls : string [ ] ;
6797} {
6898 const { document, window } = parseHTML ( '<div id="root"></div>' ) ;
6999 const matchMedia = ( media : string ) => ( {
@@ -88,15 +118,54 @@ function installGraphRenderer(initial: AgentGraphClientSnapshot): {
88118 IS_REACT_ACT_ENVIRONMENT : true ,
89119 } ) ;
90120
91- const snapshots = new Map < string , AgentGraphClientSnapshot > ( [
92- [ initial . rootSessionId , initial ] ,
93- ] ) ;
121+ const snapshots = new Map (
122+ [ initial , ...historical ] . map ( ( entry ) => [ entry . graphId , entry ] as const ) ,
123+ ) ;
124+ const currentGraphIds = new Map < string , string > ( ) ;
125+ for ( const entry of [ initial , ...historical ] ) {
126+ if ( ! currentGraphIds . has ( entry . rootSessionId ) ) {
127+ currentGraphIds . set ( entry . rootSessionId , entry . graphId ) ;
128+ }
129+ }
94130 const listeners = new Set < GraphListener > ( ) ;
131+ const epochListGates = new Map < string , DeferredReadGate > ( ) ;
132+ const snapshotGates = new Map < string , DeferredReadGate > ( ) ;
133+ const stopCalls : string [ ] = [ ] ;
95134 ( window as unknown as { maka : unknown } ) . maka = {
96135 graphs : {
97- getSnapshot : async ( sessionId : string ) => {
98- const next = snapshots . get ( sessionId ) ;
99- if ( ! next ) throw new Error ( `missing graph snapshot for ${ sessionId } ` ) ;
136+ listEpochs : async ( sessionId : string ) => {
137+ const gate = epochListGates . get ( sessionId ) ;
138+ if ( gate ) {
139+ epochListGates . delete ( sessionId ) ;
140+ gate . markStarted ( ) ;
141+ await gate . waitForRelease ;
142+ }
143+ const currentGraphId = currentGraphIds . get ( sessionId ) ;
144+ const entries = [ ...snapshots . values ( ) ]
145+ . filter ( ( entry ) => entry . rootSessionId === sessionId )
146+ . sort ( ( left , right ) => Number ( right . graphId === currentGraphId ) - Number ( left . graphId === currentGraphId ) ) ;
147+ return {
148+ epochs : entries . map ( ( entry , index ) => ( {
149+ epoch : entries . length - index ,
150+ graphId : entry . graphId ,
151+ createdAt : index + 1 ,
152+ current : currentGraphIds . get ( sessionId ) === entry . graphId ,
153+ } ) ) ,
154+ truncated : false ,
155+ } ;
156+ } ,
157+ getSnapshot : async ( sessionId : string , options ?: { graphId ?: string } ) => {
158+ const graphId = options ?. graphId ?? currentGraphIds . get ( sessionId ) ;
159+ const gate = graphId ? snapshotGates . get ( graphId ) : undefined ;
160+ if ( gate && graphId ) {
161+ snapshotGates . delete ( graphId ) ;
162+ gate . markStarted ( ) ;
163+ await gate . waitForRelease ;
164+ }
165+ const next = graphId ? snapshots . get ( graphId ) : undefined ;
166+ if ( ! next || next . rootSessionId !== sessionId ) {
167+ throw new Error ( `missing graph snapshot for ${ sessionId } ` ) ;
168+ }
100169 return next ;
101170 } ,
102171 inspectOperator : async ( ) => {
@@ -108,7 +177,9 @@ function installGraphRenderer(initial: AgentGraphClientSnapshot): {
108177 listeners . delete ( listener ) ;
109178 } ;
110179 } ,
111- stop : async ( ) => undefined ,
180+ stop : async ( sessionId : string ) => {
181+ stopCalls . push ( sessionId ) ;
182+ } ,
112183 } ,
113184 } ;
114185
@@ -119,12 +190,16 @@ function installGraphRenderer(initial: AgentGraphClientSnapshot): {
119190 container,
120191 root,
121192 async setSnapshot ( next ) {
122- snapshots . set ( next . rootSessionId , next ) ;
193+ snapshots . set ( next . graphId , next ) ;
194+ currentGraphIds . set ( next . rootSessionId , next . graphId ) ;
123195 await act ( async ( ) => {
124196 for ( const listener of [ ...listeners ] ) listener ( ) ;
125197 await Promise . resolve ( ) ;
126198 } ) ;
127199 } ,
200+ evict ( graphId ) {
201+ snapshots . delete ( graphId ) ;
202+ } ,
128203 async renderSession ( sessionId ) {
129204 await act ( async ( ) => {
130205 root . render (
@@ -138,6 +213,17 @@ function installGraphRenderer(initial: AgentGraphClientSnapshot): {
138213 await Promise . resolve ( ) ;
139214 } ) ;
140215 } ,
216+ holdNextEpochList ( sessionId ) {
217+ const gate = deferredReadGate ( ) ;
218+ epochListGates . set ( sessionId , gate ) ;
219+ return gate ;
220+ } ,
221+ holdNextSnapshot ( graphId ) {
222+ const gate = deferredReadGate ( ) ;
223+ snapshotGates . set ( graphId , gate ) ;
224+ return gate ;
225+ } ,
226+ stopCalls,
141227 } ;
142228}
143229
@@ -160,6 +246,126 @@ async function renderPanel(
160246}
161247
162248describe ( 'AgentGraphPanel dismiss' , ( ) => {
249+ it ( 'keeps the new session loading when a disposed read settles later' , async ( ) => {
250+ const sessionA = snapshot ( { graphId : 'graph-a' , status : 'active' } ) ;
251+ const sessionB = snapshot ( {
252+ graphId : 'graph-b' ,
253+ status : 'active' ,
254+ rootSessionId : 'session-2' ,
255+ } ) ;
256+ const harness = installGraphRenderer ( sessionA , [ sessionB ] ) ;
257+ const readA = harness . holdNextEpochList ( 'session-1' ) ;
258+ await harness . renderSession ( 'session-1' ) ;
259+ await readA . started ;
260+
261+ const readB = harness . holdNextEpochList ( 'session-2' ) ;
262+ await harness . renderSession ( 'session-2' ) ;
263+ await readB . started ;
264+ assert . match ( harness . container . textContent ?? '' , / L o a d i n g g r a p h s t a t e / ) ;
265+
266+ await act ( async ( ) => {
267+ readA . release ( ) ;
268+ await Promise . resolve ( ) ;
269+ } ) ;
270+ assert . match ( harness . container . textContent ?? '' , / L o a d i n g g r a p h s t a t e / ) ;
271+
272+ await act ( async ( ) => {
273+ readB . release ( ) ;
274+ await Promise . resolve ( ) ;
275+ } ) ;
276+ assert . match ( harness . container . textContent ?? '' , / A g e n t G r a p h / ) ;
277+ await act ( async ( ) => harness . root . unmount ( ) ) ;
278+ } ) ;
279+
280+ it ( 'switches to a historical epoch without exposing current-graph controls' , async ( ) => {
281+ const current = snapshot ( { graphId : 'graph-2' , status : 'active' } ) ;
282+ const previous = snapshot ( { graphId : 'graph-1' , status : 'completed' } ) ;
283+ const harness = installGraphRenderer ( current , [ previous ] ) ;
284+ await act ( async ( ) => {
285+ harness . root . render (
286+ createElement ( AgentGraphPanel , {
287+ rootSessionId : 'session-1' ,
288+ enabled : true ,
289+ locale : 'en' ,
290+ onOpenSession : ( ) => undefined ,
291+ } ) ,
292+ ) ;
293+ await Promise . resolve ( ) ;
294+ } ) ;
295+
296+ const selector = harness . container . querySelector ( '[role="combobox"]' ) ;
297+ assert . ok ( selector ) ;
298+ assert . match ( harness . container . textContent ?? '' , / S t o p g r a p h / ) ;
299+ await act ( async ( ) => {
300+ ( selector as HTMLElement ) . click ( ) ;
301+ await Promise . resolve ( ) ;
302+ } ) ;
303+ const historyOption = [ ...document . querySelectorAll ( '[role="option"]' ) ] . find ( ( option ) =>
304+ option . textContent ?. includes ( 'History' ) ,
305+ ) ;
306+ assert . ok ( historyOption ) ;
307+ const historyRead = harness . holdNextSnapshot ( 'graph-1' ) ;
308+ await act ( async ( ) => {
309+ ( historyOption as HTMLElement ) . click ( ) ;
310+ await historyRead . started ;
311+ } ) ;
312+
313+ assert . match ( harness . container . textContent ?? '' , / # 1 · H i s t o r y \( r e a d - o n l y \) / ) ;
314+ assert . doesNotMatch ( harness . container . textContent ?? '' , / S t o p g r a p h / ) ;
315+ assert . equal ( harness . container . querySelector ( '.maka-agent-graph-dismiss' ) , null ) ;
316+ assert . deepEqual ( harness . stopCalls , [ ] ) ;
317+ await act ( async ( ) => {
318+ historyRead . release ( ) ;
319+ await Promise . resolve ( ) ;
320+ } ) ;
321+ await act ( async ( ) => harness . root . unmount ( ) ) ;
322+ } ) ;
323+
324+ it ( 'resumes following the current epoch after the selected history expires' , async ( ) => {
325+ const current = snapshot ( { graphId : 'graph-2' , status : 'active' } ) ;
326+ const previous = snapshot ( { graphId : 'graph-1' , status : 'completed' } ) ;
327+ const harness = installGraphRenderer ( current , [ previous ] ) ;
328+ await act ( async ( ) => {
329+ harness . root . render (
330+ createElement ( AgentGraphPanel , {
331+ rootSessionId : 'session-1' ,
332+ enabled : true ,
333+ locale : 'en' ,
334+ onOpenSession : ( ) => undefined ,
335+ } ) ,
336+ ) ;
337+ await Promise . resolve ( ) ;
338+ } ) ;
339+
340+ const selector = harness . container . querySelector ( '[role="combobox"]' ) ;
341+ assert . ok ( selector ) ;
342+ await act ( async ( ) => {
343+ ( selector as HTMLElement ) . click ( ) ;
344+ await Promise . resolve ( ) ;
345+ } ) ;
346+ const historyOption = [ ...document . querySelectorAll ( '[role="option"]' ) ] . find ( ( option ) =>
347+ option . textContent ?. includes ( 'History' ) ,
348+ ) ;
349+ assert . ok ( historyOption ) ;
350+ await act ( async ( ) => {
351+ ( historyOption as HTMLElement ) . click ( ) ;
352+ await Promise . resolve ( ) ;
353+ } ) ;
354+ const combobox = ( ) => harness . container . querySelector ( '[role="combobox"]' ) ?. textContent ?? '' ;
355+ assert . match ( combobox ( ) , / # 1 · H i s t o r y \( r e a d - o n l y \) / ) ;
356+
357+ // The selected epoch leaves the bounded directory while the graph rolls over.
358+ harness . evict ( 'graph-1' ) ;
359+ await harness . setSnapshot ( snapshot ( { graphId : 'graph-3' , status : 'active' } ) ) ;
360+ assert . match ( combobox ( ) , / # 2 · C u r r e n t / ) ;
361+
362+ // Follow restored: the next rollover refreshes the panel instead of
363+ // staying pinned on the fallback graph.
364+ await harness . setSnapshot ( snapshot ( { graphId : 'graph-4' , status : 'waiting' } ) ) ;
365+ assert . match ( combobox ( ) , / # 3 · C u r r e n t / ) ;
366+ await act ( async ( ) => harness . root . unmount ( ) ) ;
367+ } ) ;
368+
163369 it ( 'shows dismiss only after the graph has settled' , async ( ) => {
164370 const active = await renderPanel ( snapshot ( { graphId : 'graph-1' , status : 'active' } ) ) ;
165371 assert . ok ( active . container . querySelector ( '.maka-agent-graph-panel' ) ) ;
0 commit comments