@marimo-team/frontend 0.23.2-dev38 → 0.23.2-dev39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/assets/{CellStatus-CjPI69hZ.js → CellStatus-DVCNDLN4.js} +1 -1
  2. package/dist/assets/{JsonOutput-CcRaYhT-.js → JsonOutput-CaMHqTRB.js} +1 -1
  3. package/dist/assets/{MarimoErrorOutput-BYuSTceB.js → MarimoErrorOutput-BlvHXVUx.js} +1 -1
  4. package/dist/assets/{RenderHTML-DQttWYDe.js → RenderHTML-Cz27UcDX.js} +1 -1
  5. package/dist/assets/{add-cell-with-ai-CPCm86Mb.js → add-cell-with-ai-5BBZGFp9.js} +1 -1
  6. package/dist/assets/{add-connection-dialog-dNJgn8Ox.js → add-connection-dialog-BTUO6NJ0.js} +1 -1
  7. package/dist/assets/{agent-panel-CZV8iRUC.js → agent-panel-D2MAo4Fl.js} +1 -1
  8. package/dist/assets/{ai-model-dropdown-CHNX-Yvn.js → ai-model-dropdown-DAgirzoG.js} +1 -1
  9. package/dist/assets/{app-config-button-B7ptjTn8.js → app-config-button-CM7eYfWU.js} +1 -1
  10. package/dist/assets/{cell-editor-OuSBQcHa.js → cell-editor-CTaekLvG.js} +1 -1
  11. package/dist/assets/{cell-link-CeI1GPxX.js → cell-link-D8xLFRwF.js} +1 -1
  12. package/dist/assets/{cells-CkQXmtbU.js → cells-9Zp_JbEx.js} +33 -33
  13. package/dist/assets/{chat-display-qsryOR04.js → chat-display-C9e79L_T.js} +1 -1
  14. package/dist/assets/{chat-panel-B0ITjHrb.js → chat-panel-Bd1FEqbW.js} +1 -1
  15. package/dist/assets/{chat-ui-FgswEfHK.js → chat-ui-DOiOkm2n.js} +1 -1
  16. package/dist/assets/{column-preview-hHmxmCnB.js → column-preview-CoawaYbp.js} +1 -1
  17. package/dist/assets/{command-palette-BlNedegf.js → command-palette-BlLPeMPB.js} +1 -1
  18. package/dist/assets/{common-B1gBVTty.js → common--vhf6hkz.js} +1 -1
  19. package/dist/assets/{components-GayobceR.js → components-Cdl3TnkR.js} +1 -1
  20. package/dist/assets/{components-4Llkti3I.js → components-D8goxQ66.js} +1 -1
  21. package/dist/assets/{datasource-DohSpOc8.js → datasource-AQKUhUjP.js} +1 -1
  22. package/dist/assets/{dependency-graph-panel-_bVWly6X.js → dependency-graph-panel-Dmzxw1B_.js} +1 -1
  23. package/dist/assets/{documentation-panel-DBQM75dO.js → documentation-panel-eJQ66av2.js} +1 -1
  24. package/dist/assets/{download-aayD25Wi.js → download-CsVG4qrR.js} +1 -1
  25. package/dist/assets/{edit-page-Cw8d_f2S.js → edit-page-ChHgPyka.js} +3 -3
  26. package/dist/assets/{error-panel-D-9BQN8U.js → error-panel-DMnj0nOD.js} +1 -1
  27. package/dist/assets/{file-explorer-panel-CxfXG24m.js → file-explorer-panel-DeoEBy9i.js} +1 -1
  28. package/dist/assets/{file-icons-UkZeY45t.js → file-icons-DLa7-MLk.js} +1 -1
  29. package/dist/assets/{floating-outline--H6knKWf.js → floating-outline-DdtzicWh.js} +1 -1
  30. package/dist/assets/{focus-DxuOmtKI.js → focus-COBUb5b6.js} +1 -1
  31. package/dist/assets/{form-3fPAWHbK.js → form-BNhPt7_q.js} +1 -1
  32. package/dist/assets/{home-page-_beA3R51.js → home-page-BbQwtlN0.js} +1 -1
  33. package/dist/assets/{hooks-B4ys0c0n.js → hooks-XpLDm4ny.js} +1 -1
  34. package/dist/assets/{html-to-image-RqGk5tNG.js → html-to-image-BB3CKaMw.js} +1 -1
  35. package/dist/assets/{index-lpZP7WFc.js → index-CPzBojY-.js} +4 -4
  36. package/dist/assets/{kiosk-mode-CtVLUXra.js → kiosk-mode-BVPZu333.js} +1 -1
  37. package/dist/assets/{layout-Dz6l5gDf.js → layout-CU55YmC3.js} +1 -1
  38. package/dist/assets/{logs-panel-Gg9rCqvY.js → logs-panel-C8usBneO.js} +1 -1
  39. package/dist/assets/{markdown-renderer-CPtNggG4.js → markdown-renderer-BclMAJo1.js} +1 -1
  40. package/dist/assets/{name-cell-input-D3CxUPEc.js → name-cell-input-B_6HAmm5.js} +1 -1
  41. package/dist/assets/{outline-panel-COUl_K0O.js → outline-panel-ljJ_fuK8.js} +1 -1
  42. package/dist/assets/{packages-panel-C_MusfCT.js → packages-panel-4kAgcZnP.js} +1 -1
  43. package/dist/assets/{panels-DwDu5SGI.js → panels-ncrySLKb.js} +1 -1
  44. package/dist/assets/{process-output-B4IfPdhN.js → process-output-GJTUEkWa.js} +1 -1
  45. package/dist/assets/{readonly-python-code-CcyUzrM-.js → readonly-python-code-Lck67mJo.js} +1 -1
  46. package/dist/assets/{run-page-C2kQLw9h.js → run-page-ZSp9uttp.js} +1 -1
  47. package/dist/assets/{scratchpad-panel-BN9Sv2Hh.js → scratchpad-panel-CEN-dBEu.js} +1 -1
  48. package/dist/assets/{session-panel-CMI4faW3.js → session-panel-CcYW5DIV.js} +1 -1
  49. package/dist/assets/{snippets-panel-lJwSEA5M.js → snippets-panel-B87fFN0r.js} +1 -1
  50. package/dist/assets/{state-BoK4XWzN.js → state-CALDPZOE.js} +1 -1
  51. package/dist/assets/{state-DbB3Bx1n.js → state-DF3-mDqB.js} +1 -1
  52. package/dist/assets/{textarea-jLEyGvlo.js → textarea-DwVik6yG.js} +1 -1
  53. package/dist/assets/{tracing-CFor4yuK.js → tracing-BzXQ5H6_.js} +1 -1
  54. package/dist/assets/{tracing-panel-DM9aAZvv.js → tracing-panel-DyqA4WqJ.js} +2 -2
  55. package/dist/assets/{useAddCell-pxYjm9Vz.js → useAddCell-DgomfDJt.js} +1 -1
  56. package/dist/assets/{useCellActionButton-GNljpGS_.js → useCellActionButton-CfZZmpoB.js} +1 -1
  57. package/dist/assets/{useDeleteCell-1EiCOF19.js → useDeleteCell-DNcoM-0z.js} +1 -1
  58. package/dist/assets/{useDependencyPanelTab-DLosV83A.js → useDependencyPanelTab-eVqw00QY.js} +1 -1
  59. package/dist/assets/{useNotebookActions-D_eNWvNH.js → useNotebookActions-Di_CkPfK.js} +1 -1
  60. package/dist/assets/{useRunCells-BGxIfw3R.js → useRunCells-DM6jetbr.js} +1 -1
  61. package/dist/assets/{useSplitCell-oguWPZqA.js → useSplitCell-BDm4rmCg.js} +1 -1
  62. package/dist/index.html +22 -22
  63. package/package.json +1 -1
  64. package/src/core/cells/__tests__/apply-transaction.test.ts +441 -0
  65. package/src/core/cells/__tests__/cells.test.ts +110 -0
  66. package/src/core/cells/cells.ts +18 -0
  67. package/src/core/cells/document-changes.ts +34 -1
@@ -106,6 +106,14 @@ function pretty(s: NotebookState): string {
106
106
  return `\n${lines.join("\n")}\n`;
107
107
  }
108
108
 
109
+ /** Snapshot showing the physical column grouping in the MultiColumn tree. */
110
+ function prettyColumns(s: NotebookState): string {
111
+ const lines = s.cellIds
112
+ .getColumns()
113
+ .map((col, idx) => `col${idx}: [${col.inOrderIds.join(", ")}]`);
114
+ return `\n${lines.join("\n")}\n`;
115
+ }
116
+
109
117
  let i = 0;
110
118
 
111
119
  beforeAll(() => {
@@ -134,6 +142,14 @@ describe("applyTransactionChanges edge cases", () => {
134
142
  new-cell: 'configured' [hide_code, disabled, col=1]
135
143
  "
136
144
  `);
145
+ // The new cell must physically land in the second column, not just
146
+ // carry col=1 as stale metadata.
147
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
148
+ "
149
+ col0: [0]
150
+ col1: [new-cell]
151
+ "
152
+ `);
137
153
  });
138
154
 
139
155
  it("create-cell then move-cell in same transaction", () => {
@@ -348,3 +364,428 @@ describe("applyTransactionChanges edge cases", () => {
348
364
  expect(editorView?.state.doc.toString()).toBe('x = "AFTER"');
349
365
  });
350
366
  });
367
+
368
+ describe("applyTransactionChanges column rebuild", () => {
369
+ it("boundary anchors: set-config on column boundaries splits cells into columns", () => {
370
+ // The user's exact example. Server sends a reorder + set-config only on
371
+ // the cells at column boundaries. The replica must infer that the cells
372
+ // in between inherit the column of the preceding anchor.
373
+ setup("a", "b", "c", "d");
374
+ const [a, b, c, d] = state.cellIds.inOrderIds;
375
+ apply([
376
+ { type: "reorder-cells", cellIds: [a, b, c, d] },
377
+ {
378
+ type: "set-config",
379
+ cellId: a,
380
+ column: 0,
381
+ disabled: false,
382
+ hideCode: false,
383
+ },
384
+ {
385
+ type: "set-config",
386
+ cellId: c,
387
+ column: 1,
388
+ disabled: false,
389
+ hideCode: false,
390
+ },
391
+ ]);
392
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
393
+ "
394
+ col0: [0, 1]
395
+ col1: [2, 3]
396
+ "
397
+ `);
398
+ });
399
+
400
+ it("boundary anchors on reordered cells: order follows reorder-cells", () => {
401
+ // Start from a single column, reorder the cells and split at c.
402
+ // The rebuild must use the new flat order from reorder-cells so that
403
+ // b ends up next to a (col 0) and d ends up next to c (col 1).
404
+ setup("a", "b", "c", "d");
405
+ const [a, b, c, d] = state.cellIds.inOrderIds;
406
+ apply([
407
+ { type: "reorder-cells", cellIds: [d, a, c, b] },
408
+ {
409
+ type: "set-config",
410
+ cellId: d,
411
+ column: 0,
412
+ disabled: false,
413
+ hideCode: false,
414
+ },
415
+ {
416
+ type: "set-config",
417
+ cellId: c,
418
+ column: 1,
419
+ disabled: false,
420
+ hideCode: false,
421
+ },
422
+ ]);
423
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
424
+ "
425
+ col0: [3, 0]
426
+ col1: [2, 1]
427
+ "
428
+ `);
429
+ });
430
+
431
+ it("three columns: inherits column through consecutive cells", () => {
432
+ setup("a", "b", "c", "d", "e", "f");
433
+ const [a, b, c, d, e, f] = state.cellIds.inOrderIds;
434
+ apply([
435
+ { type: "reorder-cells", cellIds: [a, b, c, d, e, f] },
436
+ {
437
+ type: "set-config",
438
+ cellId: a,
439
+ column: 0,
440
+ disabled: false,
441
+ hideCode: false,
442
+ },
443
+ {
444
+ type: "set-config",
445
+ cellId: c,
446
+ column: 1,
447
+ disabled: false,
448
+ hideCode: false,
449
+ },
450
+ {
451
+ type: "set-config",
452
+ cellId: e,
453
+ column: 2,
454
+ disabled: false,
455
+ hideCode: false,
456
+ },
457
+ ]);
458
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
459
+ "
460
+ col0: [0, 1]
461
+ col1: [2, 3]
462
+ col2: [4, 5]
463
+ "
464
+ `);
465
+ });
466
+
467
+ it("every cell explicitly tagged with a column", () => {
468
+ setup("a", "b", "c", "d");
469
+ const [a, b, c, d] = state.cellIds.inOrderIds;
470
+ apply([
471
+ { type: "reorder-cells", cellIds: [a, b, c, d] },
472
+ { type: "set-config", cellId: a, column: 0 },
473
+ { type: "set-config", cellId: b, column: 1 },
474
+ { type: "set-config", cellId: c, column: 0 },
475
+ { type: "set-config", cellId: d, column: 1 },
476
+ ]);
477
+ // a and c in col0; b and d in col1. Order within each column follows
478
+ // the reorder-cells order.
479
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
480
+ "
481
+ col0: [0, 2]
482
+ col1: [1, 3]
483
+ "
484
+ `);
485
+ });
486
+
487
+ it("set-config without reorder-cells moves the cell to the new column", () => {
488
+ setup("a", "b", "c");
489
+ const [, b] = state.cellIds.inOrderIds;
490
+ apply([{ type: "set-config", cellId: b, column: 1 }]);
491
+ // Without reorder-cells, the flat order comes from the current tree.
492
+ // b gets explicit col=1; a and c stay with default null → follow the
493
+ // previous cell's column (a → col0 because prev=0; c → col1 because
494
+ // prev was just set to 1 by b).
495
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
496
+ "
497
+ col0: [0]
498
+ col1: [1, 2]
499
+ "
500
+ `);
501
+ });
502
+
503
+ it("no column change: set-config only touching other fields does not repartition", () => {
504
+ setup("a", "b", "c");
505
+ const [, b] = state.cellIds.inOrderIds;
506
+ apply([{ type: "set-config", cellId: b, hideCode: true }]);
507
+ // All three cells remain in a single column. No rebuild triggered.
508
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
509
+ "
510
+ col0: [0, 1, 2]
511
+ "
512
+ `);
513
+ expect(pretty(state)).toMatchInlineSnapshot(`
514
+ "
515
+ 0: 'a'
516
+ 1: 'b' [hide_code]
517
+ 2: 'c'
518
+ "
519
+ `);
520
+ });
521
+
522
+ it("no changes: empty transaction leaves column structure alone", () => {
523
+ // Setup a multi-column state first
524
+ setup("a", "b");
525
+ const [a, b] = state.cellIds.inOrderIds;
526
+ apply([
527
+ { type: "reorder-cells", cellIds: [a, b] },
528
+ { type: "set-config", cellId: a, column: 0 },
529
+ { type: "set-config", cellId: b, column: 1 },
530
+ ]);
531
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
532
+ "
533
+ col0: [0]
534
+ col1: [1]
535
+ "
536
+ `);
537
+ // Now apply no changes — column structure should be preserved.
538
+ apply([]);
539
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
540
+ "
541
+ col0: [0]
542
+ col1: [1]
543
+ "
544
+ `);
545
+ });
546
+
547
+ it("merging columns: set-config col=0 for everything collapses to one column", () => {
548
+ // Start in a multi-column state.
549
+ setup("a", "b", "c", "d");
550
+ const [a, b, c, d] = state.cellIds.inOrderIds;
551
+ apply([
552
+ { type: "reorder-cells", cellIds: [a, b, c, d] },
553
+ { type: "set-config", cellId: a, column: 0 },
554
+ { type: "set-config", cellId: c, column: 1 },
555
+ ]);
556
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
557
+ "
558
+ col0: [0, 1]
559
+ col1: [2, 3]
560
+ "
561
+ `);
562
+ // Now merge everything back to col 0.
563
+ apply([
564
+ { type: "reorder-cells", cellIds: [a, b, c, d] },
565
+ { type: "set-config", cellId: c, column: 0 },
566
+ { type: "set-config", cellId: d, column: 0 },
567
+ ]);
568
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
569
+ "
570
+ col0: [0, 1, 2, 3]
571
+ "
572
+ `);
573
+ });
574
+
575
+ it("create-cell with column places cell in correct column", () => {
576
+ setup("a", "b");
577
+ const [, b] = state.cellIds.inOrderIds;
578
+ apply([
579
+ {
580
+ type: "create-cell",
581
+ cellId: cellId("fresh"),
582
+ code: "x",
583
+ name: "",
584
+ config: { column: 1 },
585
+ after: b,
586
+ },
587
+ ]);
588
+ // a and b are in col 0 (unchanged). The new cell is created at the end
589
+ // with column=1, so it should land physically in col 1.
590
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
591
+ "
592
+ col0: [0, 1]
593
+ col1: [fresh]
594
+ "
595
+ `);
596
+ expect(pretty(state)).toMatchInlineSnapshot(`
597
+ "
598
+ 0: 'a'
599
+ 1: 'b'
600
+ fresh: 'x' [col=1]
601
+ "
602
+ `);
603
+ });
604
+
605
+ it("multi-change transaction with column + code + name updates", () => {
606
+ setup("a", "b", "c");
607
+ const [a, b, c] = state.cellIds.inOrderIds;
608
+ apply([
609
+ { type: "reorder-cells", cellIds: [a, b, c] },
610
+ { type: "set-code", cellId: a, code: "x = 1" },
611
+ { type: "set-name", cellId: b, name: "middle" },
612
+ { type: "set-config", cellId: a, column: 0 },
613
+ { type: "set-config", cellId: b, column: 1 },
614
+ ]);
615
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
616
+ "
617
+ col0: [0]
618
+ col1: [1, 2]
619
+ "
620
+ `);
621
+ expect(pretty(state)).toMatchInlineSnapshot(`
622
+ "
623
+ 0: 'x = 1' [col=0]
624
+ 1: 'b' [name=middle, col=1]
625
+ 2: 'c'
626
+ "
627
+ `);
628
+ });
629
+
630
+ it("cancelled create+delete does not trigger column rebuild", () => {
631
+ setup("a", "b");
632
+ apply([
633
+ {
634
+ type: "create-cell",
635
+ cellId: cellId("ephemeral"),
636
+ code: "tmp",
637
+ name: "",
638
+ config: { column: 1 },
639
+ },
640
+ { type: "delete-cell", cellId: cellId("ephemeral") },
641
+ ]);
642
+ // The create+delete cancel out. The column metadata on the cancelled
643
+ // create-cell shouldn't cause a spurious column rebuild.
644
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
645
+ "
646
+ col0: [0, 1]
647
+ "
648
+ `);
649
+ });
650
+
651
+ it("boundary-anchor convention: moving an anchor pulls non-anchored followers", () => {
652
+ // This documents the boundary-anchor convention used by the server: only
653
+ // cells at column boundaries carry an explicit column. Non-anchor cells
654
+ // have config.column=null and inherit the column of the previous cell.
655
+ // That means moving an anchor also moves its silent followers.
656
+ setup("a", "b", "c", "d");
657
+ const [a, b, c, d] = state.cellIds.inOrderIds;
658
+ // Split into two columns. Only a and c are anchors.
659
+ apply([
660
+ { type: "reorder-cells", cellIds: [a, b, c, d] },
661
+ { type: "set-config", cellId: a, column: 0 },
662
+ { type: "set-config", cellId: c, column: 1 },
663
+ ]);
664
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
665
+ "
666
+ col0: [0, 1]
667
+ col1: [2, 3]
668
+ "
669
+ `);
670
+ // Move the c anchor to col 0. d has no explicit column so it follows c.
671
+ apply([
672
+ { type: "reorder-cells", cellIds: [a, b, c, d] },
673
+ { type: "set-config", cellId: c, column: 0 },
674
+ ]);
675
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
676
+ "
677
+ col0: [0, 1, 2, 3]
678
+ "
679
+ `);
680
+ });
681
+
682
+ it("explicit anchors on followers: move anchor leaves explicitly-tagged follower behind", () => {
683
+ // Contrast with the previous test: if d is explicitly tagged col=1,
684
+ // moving c back to col 0 should leave d in col 1.
685
+ setup("a", "b", "c", "d");
686
+ const [a, b, c, d] = state.cellIds.inOrderIds;
687
+ apply([
688
+ { type: "reorder-cells", cellIds: [a, b, c, d] },
689
+ { type: "set-config", cellId: a, column: 0 },
690
+ { type: "set-config", cellId: c, column: 1 },
691
+ { type: "set-config", cellId: d, column: 1 },
692
+ ]);
693
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
694
+ "
695
+ col0: [0, 1]
696
+ col1: [2, 3]
697
+ "
698
+ `);
699
+ // Move c back to col 0. Because d has its own explicit column=1, it
700
+ // does NOT follow c.
701
+ apply([
702
+ { type: "reorder-cells", cellIds: [a, b, c, d] },
703
+ { type: "set-config", cellId: c, column: 0 },
704
+ ]);
705
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
706
+ "
707
+ col0: [0, 1, 2]
708
+ col1: [3]
709
+ "
710
+ `);
711
+ });
712
+
713
+ it("set-config before reorder-cells: processing is sorted so set-config runs last", () => {
714
+ // Defensive test: even if a transaction has set-config *before*
715
+ // reorder-cells, the implementation must process reorder-cells first so
716
+ // that the column rebuild sees the intended final flat order. This
717
+ // matches the order the backend plans transactions in.
718
+ setup("a", "b", "c", "d");
719
+ const [a, b, c, d] = state.cellIds.inOrderIds;
720
+ apply([
721
+ // set-config appears FIRST in the transaction.
722
+ { type: "set-config", cellId: a, column: 0 },
723
+ { type: "set-config", cellId: c, column: 1 },
724
+ // reorder-cells comes afterwards.
725
+ { type: "reorder-cells", cellIds: [a, b, c, d] },
726
+ ]);
727
+ // Result must be identical to the canonical order:
728
+ // the tree is reshaped first, then column metadata is applied.
729
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
730
+ "
731
+ col0: [0, 1]
732
+ col1: [2, 3]
733
+ "
734
+ `);
735
+ });
736
+
737
+ it("set-config interleaved between other changes is still run last", () => {
738
+ // Another ordering variant: set-config interleaved between set-code and
739
+ // reorder-cells. Our sort must move set-config to the end regardless of
740
+ // position, while preserving the relative order of the other changes.
741
+ setup("a", "b", "c", "d");
742
+ const [a, b, c, d] = state.cellIds.inOrderIds;
743
+ apply([
744
+ { type: "set-config", cellId: a, column: 0 },
745
+ { type: "set-code", cellId: a, code: "x = 1" },
746
+ { type: "set-config", cellId: c, column: 1 },
747
+ { type: "reorder-cells", cellIds: [a, b, c, d] },
748
+ { type: "set-name", cellId: d, name: "last" },
749
+ ]);
750
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
751
+ "
752
+ col0: [0, 1]
753
+ col1: [2, 3]
754
+ "
755
+ `);
756
+ expect(pretty(state)).toMatchInlineSnapshot(`
757
+ "
758
+ 0: 'x = 1' [col=0]
759
+ 1: 'b'
760
+ 2: 'c' [col=1]
761
+ 3: 'd' [name=last]
762
+ "
763
+ `);
764
+ });
765
+
766
+ it("reorder-cells alone (no column changes) preserves existing columns", () => {
767
+ // Start in a two-column state.
768
+ setup("a", "b", "c", "d");
769
+ const [a, b, c, d] = state.cellIds.inOrderIds;
770
+ apply([
771
+ { type: "reorder-cells", cellIds: [a, b, c, d] },
772
+ { type: "set-config", cellId: a, column: 0 },
773
+ { type: "set-config", cellId: c, column: 1 },
774
+ ]);
775
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
776
+ "
777
+ col0: [0, 1]
778
+ col1: [2, 3]
779
+ "
780
+ `);
781
+ // Now reorder without any column changes. The rebuild must NOT fire.
782
+ // setCellIds with fromWithPreviousShape preserves the column assignments.
783
+ apply([{ type: "reorder-cells", cellIds: [b, a, d, c] }]);
784
+ expect(prettyColumns(state)).toMatchInlineSnapshot(`
785
+ "
786
+ col0: [1, 0]
787
+ col1: [3, 2]
788
+ "
789
+ `);
790
+ });
791
+ });
@@ -2206,6 +2206,116 @@ describe("cell reducer", () => {
2206
2206
  expect(state.cellIds.getColumns()[2].topLevelIds).toEqual([cellId("2")]);
2207
2207
  });
2208
2208
 
2209
+ it("rebuildCellColumns regroups cells by config.column", () => {
2210
+ // Create four cells in a single column.
2211
+ actions.createNewCell({ cellId: firstCellId, before: false });
2212
+ actions.createNewCell({ cellId: cellId("1"), before: false });
2213
+ actions.createNewCell({ cellId: cellId("2"), before: false });
2214
+ expect(state.cellIds.getColumns().length).toBe(1);
2215
+
2216
+ // Explicitly set config.column on each cell. This ONLY updates metadata —
2217
+ // updateCellConfig does not touch the MultiColumn tree.
2218
+ actions.updateCellConfig({
2219
+ cellId: cellId("0"),
2220
+ config: { column: 0 },
2221
+ });
2222
+ actions.updateCellConfig({
2223
+ cellId: cellId("2"),
2224
+ config: { column: 1 },
2225
+ });
2226
+
2227
+ // Tree is still a single column at this point.
2228
+ expect(state.cellIds.getColumns().length).toBe(1);
2229
+
2230
+ // Now rebuild the column tree from metadata.
2231
+ actions.rebuildCellColumns({
2232
+ cellIds: [cellId("0"), cellId("1"), cellId("2"), cellId("3")],
2233
+ });
2234
+
2235
+ expect(state.cellIds.getColumns().length).toBe(2);
2236
+ // Cell 1 inherits from 0 (col 0), cell 3 inherits from 2 (col 1).
2237
+ expect(state.cellIds.getColumns()[0].topLevelIds).toEqual([
2238
+ cellId("0"),
2239
+ cellId("1"),
2240
+ ]);
2241
+ expect(state.cellIds.getColumns()[1].topLevelIds).toEqual([
2242
+ cellId("2"),
2243
+ cellId("3"),
2244
+ ]);
2245
+ });
2246
+
2247
+ it("rebuildCellColumns with explicit column on every cell", () => {
2248
+ actions.createNewCell({ cellId: firstCellId, before: false });
2249
+ actions.createNewCell({ cellId: cellId("1"), before: false });
2250
+ actions.createNewCell({ cellId: cellId("2"), before: false });
2251
+
2252
+ for (const [id, col] of [
2253
+ ["0", 1],
2254
+ ["1", 0],
2255
+ ["2", 1],
2256
+ ["3", 0],
2257
+ ] as const) {
2258
+ actions.updateCellConfig({ cellId: cellId(id), config: { column: col } });
2259
+ }
2260
+
2261
+ actions.rebuildCellColumns({
2262
+ cellIds: [cellId("0"), cellId("1"), cellId("2"), cellId("3")],
2263
+ });
2264
+
2265
+ expect(state.cellIds.getColumns().length).toBe(2);
2266
+ expect(state.cellIds.getColumns()[0].topLevelIds).toEqual([
2267
+ cellId("1"),
2268
+ cellId("3"),
2269
+ ]);
2270
+ expect(state.cellIds.getColumns()[1].topLevelIds).toEqual([
2271
+ cellId("0"),
2272
+ cellId("2"),
2273
+ ]);
2274
+ });
2275
+
2276
+ it("rebuildCellColumns collapses to one column when all cells are col 0", () => {
2277
+ actions.createNewCell({ cellId: firstCellId, before: false });
2278
+ actions.addColumnBreakpoint({ cellId: cellId("1") });
2279
+ expect(state.cellIds.getColumns().length).toBe(2);
2280
+
2281
+ // Wipe column metadata back to 0 for both cells.
2282
+ actions.updateCellConfig({ cellId: cellId("0"), config: { column: 0 } });
2283
+ actions.updateCellConfig({ cellId: cellId("1"), config: { column: 0 } });
2284
+
2285
+ actions.rebuildCellColumns({
2286
+ cellIds: [cellId("0"), cellId("1")],
2287
+ });
2288
+
2289
+ expect(state.cellIds.getColumns().length).toBe(1);
2290
+ expect(state.cellIds.getColumns()[0].topLevelIds).toEqual([
2291
+ cellId("0"),
2292
+ cellId("1"),
2293
+ ]);
2294
+ });
2295
+
2296
+ it("rebuildCellColumns follows the provided order, not current tree order", () => {
2297
+ // Start with a single column.
2298
+ actions.createNewCell({ cellId: firstCellId, before: false });
2299
+ actions.createNewCell({ cellId: cellId("1"), before: false });
2300
+ actions.updateCellConfig({ cellId: cellId("0"), config: { column: 0 } });
2301
+ actions.updateCellConfig({ cellId: cellId("2"), config: { column: 1 } });
2302
+
2303
+ // Pass the reversed order. The rebuild should honor it.
2304
+ actions.rebuildCellColumns({
2305
+ cellIds: [cellId("2"), cellId("1"), cellId("0")],
2306
+ });
2307
+
2308
+ // cellIds iteration:
2309
+ // 2 → col=1, pushed to col1, prev=1
2310
+ // 1 → col=null, pushed to col[prev=1] = col1
2311
+ // 0 → col=0, pushed to col0
2312
+ expect(state.cellIds.getColumns()[0].topLevelIds).toEqual([cellId("0")]);
2313
+ expect(state.cellIds.getColumns()[1].topLevelIds).toEqual([
2314
+ cellId("2"),
2315
+ cellId("1"),
2316
+ ]);
2317
+ });
2318
+
2209
2319
  it("can clear output of a single cell", () => {
2210
2320
  // Set up initial state with output
2211
2321
  actions.handleCellMessage({
@@ -874,6 +874,24 @@ const {
874
874
  cellHandles: nextCellHandles,
875
875
  };
876
876
  },
877
+ /**
878
+ * Rebuild the MultiColumn tree using each cell's `config.column` value.
879
+ *
880
+ * Used after a transaction whose `set-config` changes updated cells'
881
+ * column metadata without physically moving them in the tree. Cells with
882
+ * `config.column == null` inherit the column of the previous cell in the
883
+ * given order (see `MultiColumn.fromIdsAndColumns`), which lets the server
884
+ * send column changes only at column boundaries.
885
+ */
886
+ rebuildCellColumns: (state, action: { cellIds: CellId[] }) => {
887
+ const newCellIds = MultiColumn.fromIdsAndColumns(
888
+ action.cellIds.map((id) => [
889
+ id,
890
+ state.cellData[id]?.config.column ?? null,
891
+ ]),
892
+ );
893
+ return { ...state, cellIds: newCellIds };
894
+ },
877
895
  setCellCodes: (
878
896
  state,
879
897
  action: {
@@ -349,6 +349,7 @@ export function toDocumentChanges(
349
349
  case "prepareForRun":
350
350
  case "handleCellMessage":
351
351
  case "setCellIds":
352
+ case "rebuildCellColumns":
352
353
  case "setCellCodes":
353
354
  case "setCells":
354
355
  case "setStdinResponse":
@@ -624,7 +625,24 @@ export function applyTransactionChanges(
624
625
  ): void {
625
626
  const cancelled = cancelledCellIds(changes);
626
627
 
627
- for (const change of changes) {
628
+ // Process set-config changes after everything else. The tree must be fully
629
+ // restructured (create-cell, delete-cell, reorder-cells, move-cell) before
630
+ // we start applying column metadata, since the follow-up rebuildCellColumns
631
+ // step interprets each cell's config.column against the *final* flat order.
632
+ // Sorting is stable within each group.
633
+ const sortedChanges: TransactionChange[] = [
634
+ ...changes.filter((c) => c.type !== "set-config"),
635
+ ...changes.filter((c) => c.type === "set-config"),
636
+ ];
637
+
638
+ // Track whether any change updated a cell's column, and remember the final
639
+ // flat order produced by a reorder-cells change (if any). After all changes
640
+ // are applied, these are used to rebuild the MultiColumn tree so that cells
641
+ // physically move to the column their metadata says they belong in.
642
+ let hasColumnChange = false;
643
+ let reorderOrder: CellId[] | null = null;
644
+
645
+ for (const change of sortedChanges) {
628
646
  if (
629
647
  cancelled.size > 0 &&
630
648
  "cellId" in change &&
@@ -632,11 +650,26 @@ export function applyTransactionChanges(
632
650
  ) {
633
651
  continue;
634
652
  }
653
+ if (change.type === "set-config" && change.column != null) {
654
+ hasColumnChange = true;
655
+ }
656
+ if (change.type === "create-cell" && change.config?.column != null) {
657
+ hasColumnChange = true;
658
+ }
659
+ if (change.type === "reorder-cells") {
660
+ reorderOrder = change.cellIds as CellId[];
661
+ }
635
662
  for (const action of fromDocumentChanges([change], getCurrentCellIds)) {
636
663
  // @ts-expect-error - TypeScript is not smart enough to know we have correctly mapped type -> payload
637
664
  actions[action.type](action.payload);
638
665
  }
639
666
  }
667
+
668
+ if (hasColumnChange) {
669
+ actions.rebuildCellColumns({
670
+ cellIds: reorderOrder ?? getCurrentCellIds(),
671
+ });
672
+ }
640
673
  }
641
674
 
642
675
  // ---------------------------------------------------------------------------