@@ -22,6 +22,7 @@ import {
2222 INITIAL_SPEC , specReducer , type SpecState , type TopologyFactory ,
2323} from "@/state/spec" ;
2424import { migrate } from "@/state/migrations" ;
25+ import { useHistory } from "@/hooks/useHistory" ;
2526import type { ShardingProposalView } from "@/components/sidebar/ShardingTab" ;
2627
2728// PRESETS list is now fetched dynamically from the backend via
@@ -90,6 +91,18 @@ export function App(): JSX.Element {
9091 const [ projectName , setProjectName ] = useState ( "untitled" ) ;
9192 const [ proposals , setProposals ] = useState < ShardingProposalView [ ] > ( [ ] ) ;
9293 const [ spec , dispatch ] = useReducer ( specReducer , INITIAL_SPEC ) ;
94+ // V7-H03: bounded undo/redo. Snapshot is the (nodes, edges, spec)
95+ // triple captured on every Verify cycle (cheap proxy for any
96+ // user-meaningful mutation that landed). Undo/redo restore the
97+ // snapshot triple in one shot.
98+ const history = useHistory < { nodes : Node [ ] ; edges : Edge [ ] ;
99+ spec : SpecState } > ( 50 ) ;
100+ const lastHistoryKeyRef = useRef < string > ( "" ) ;
101+ // V7-H03: when undo/redo applies a snapshot back, the resulting
102+ // state-change effect would otherwise push that snapshot AGAIN
103+ // and clear the redo stack. This ref lets the apply-path skip
104+ // the next push.
105+ const suppressNextHistoryPushRef = useRef < boolean > ( false ) ;
93106 const [ activeTab , setActiveTab ] = useState < AppTab > ( "canvas" ) ;
94107 const [ runReport , setRunReport ] = useState < RunReport | null > ( null ) ;
95108 const [ runError , setRunError ] = useState < string | null > ( null ) ;
@@ -211,6 +224,57 @@ export function App(): JSX.Element {
211224 rewriterKey , sideChannelKey , availableSideChannelKey ,
212225 scheduleVerify ] ) ;
213226
227+ // V7-H03: snapshot the (nodes, edges, spec) triple every time a
228+ // structural key changes — that's the same set of user-meaningful
229+ // mutations the verify debouncer reacts to.
230+ useEffect ( ( ) => {
231+ const key = `${ nodesKey } |${ edgesKey } |${ lossKey } |${ optimKey } |`
232+ + `${ shardingKey } |${ rewriterKey } |${ sideChannelKey } ` ;
233+ if ( key === lastHistoryKeyRef . current ) return ;
234+ lastHistoryKeyRef . current = key ;
235+ if ( suppressNextHistoryPushRef . current ) {
236+ suppressNextHistoryPushRef . current = false ;
237+ return ;
238+ }
239+ history . push ( { nodes, edges, spec } ) ;
240+ } , [ nodesKey , edgesKey , lossKey , optimKey , shardingKey ,
241+ rewriterKey , sideChannelKey , history , nodes , edges , spec ] ) ;
242+
243+ const handleUndo = useCallback ( ( ) => {
244+ const prev = history . undo ( ) ;
245+ if ( prev ) {
246+ suppressNextHistoryPushRef . current = true ;
247+ setNodes ( prev . nodes ) ;
248+ setEdges ( prev . edges ) ;
249+ dispatch ( { type : "spec.replace" , spec : prev . spec } ) ;
250+ }
251+ } , [ history ] ) ;
252+ const handleRedo = useCallback ( ( ) => {
253+ const nxt = history . redo ( ) ;
254+ if ( nxt ) {
255+ suppressNextHistoryPushRef . current = true ;
256+ setNodes ( nxt . nodes ) ;
257+ setEdges ( nxt . edges ) ;
258+ dispatch ( { type : "spec.replace" , spec : nxt . spec } ) ;
259+ }
260+ } , [ history ] ) ;
261+
262+ // V7-H03: Cmd/Ctrl+Z = undo, Shift+Cmd/Ctrl+Z = redo.
263+ useEffect ( ( ) => {
264+ function onKey ( ev : KeyboardEvent ) {
265+ const meta = ev . metaKey || ev . ctrlKey ;
266+ if ( ! meta || ev . key . toLowerCase ( ) !== "z" ) return ;
267+ // Don't intercept inside text inputs.
268+ const tag = ( ev . target as HTMLElement | null ) ?. tagName ?? "" ;
269+ if ( tag === "INPUT" || tag === "TEXTAREA" ) return ;
270+ ev . preventDefault ( ) ;
271+ if ( ev . shiftKey ) handleRedo ( ) ;
272+ else handleUndo ( ) ;
273+ }
274+ window . addEventListener ( "keydown" , onKey ) ;
275+ return ( ) => window . removeEventListener ( "keydown" , onKey ) ;
276+ } , [ handleUndo , handleRedo ] ) ;
277+
214278 // ----- Handlers ----------------------------------------------------------
215279
216280 const handleDropBrick = useCallback (
@@ -459,6 +523,10 @@ export function App(): JSX.Element {
459523 trainInFlight = { trainInFlight }
460524 trainRunId = { trainRunId }
461525 onCancelTrain = { handleCancelTrain }
526+ onUndo = { handleUndo }
527+ onRedo = { handleRedo }
528+ canUndo = { history . canUndo }
529+ canRedo = { history . canRedo }
462530 onMixedPrecisionChange = { ( enabled ) => dispatch ( { type : "optim.set" ,
463531 optim : { ...spec . optim , mixed_precision : enabled } } ) }
464532 onFp8EnabledChange = { ( enabled ) => dispatch ( { type : "sharding.set" ,
0 commit comments