@datagrok/sequence-translator 1.10.18 → 1.10.20

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.
@@ -1,3 +1,4 @@
1
+ /* eslint-disable max-lines */
1
2
  /* eslint-disable max-lines-per-function */
2
3
  /* eslint-disable max-len */
3
4
  import * as grok from 'datagrok-api/grok';
@@ -23,7 +24,7 @@ import {
23
24
  import {_package} from '../package';
24
25
  import {defaultErrorHandler} from '../utils/err-info';
25
26
 
26
- const DIALOG_TITLE = 'PolyTool Chem Enumeration';
27
+ const DIALOG_TITLE = 'Markush Enumerator';
27
28
 
28
29
  const MODE_TOOLTIPS: Record<ChemEnumMode, string> = {
29
30
  [ChemEnumModes.Zip]: 'Zip: every R-group list must have the same length N. The i-th result uses the i-th entry from every list. Produces N results per core.',
@@ -478,6 +479,149 @@ interface ChemEnumDialogState {
478
479
  appendToTable: DG.DataFrame | null;
479
480
  }
480
481
 
482
+ // ─── History (localStorage-backed, shared by dialog + app) ──────────────────
483
+
484
+ const HISTORY_KEY = 'd4-markush-enumeration-history';
485
+ const HISTORY_MAX = 10;
486
+
487
+ interface ChemEnumHistoryEntry {
488
+ /** ISO timestamp of when the enumeration was recorded. */
489
+ date: string;
490
+ /** One-line summary: `"6 cores · R1:6 · R2:4"`. */
491
+ summary: string;
492
+ mode: ChemEnumMode;
493
+ /** Original (unnormalized) SMILES for each core. */
494
+ cores: string[];
495
+ /** R-number → list of original R-group SMILES. JSON object so we can round-trip. */
496
+ rGroups: { [rNumber: string]: string[] };
497
+ }
498
+
499
+ function readHistory(): ChemEnumHistoryEntry[] {
500
+ try {
501
+ const raw = localStorage.getItem(HISTORY_KEY);
502
+ if (!raw) return [];
503
+ const arr = JSON.parse(raw);
504
+ return Array.isArray(arr) ? arr : [];
505
+ } catch {
506
+ return [];
507
+ }
508
+ }
509
+
510
+ function writeHistory(entries: ChemEnumHistoryEntry[]): void {
511
+ try {
512
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(entries));
513
+ } catch {
514
+ // Quota exceeded or storage unavailable — silently skip; history is best-effort.
515
+ }
516
+ }
517
+
518
+ function summarizeState(state: ChemEnumDialogState): string {
519
+ const partsR: string[] = [];
520
+ const sortedNums = [...state.rGroupsByNum.keys()].sort((a, b) => a - b);
521
+ for (const n of sortedNums) partsR.push(`R${n}:${state.rGroupsByNum.get(n)!.length}`);
522
+ return `${state.cores.length} core${state.cores.length === 1 ? '' : 's'}` +
523
+ (partsR.length ? ' · ' + partsR.join(' · ') : '');
524
+ }
525
+
526
+ function buildHistoryEntry(state: ChemEnumDialogState): ChemEnumHistoryEntry {
527
+ const rGroups: { [n: string]: string[] } = {};
528
+ for (const [n, list] of state.rGroupsByNum)
529
+ rGroups[String(n)] = list.map((rg) => rg.originalSmiles);
530
+ return {
531
+ date: new Date().toISOString(),
532
+ summary: summarizeState(state),
533
+ mode: state.mode,
534
+ cores: state.cores.map((c) => c.originalSmiles),
535
+ rGroups,
536
+ };
537
+ }
538
+
539
+ /** Prepends `state` as a new entry, keeps at most {@link HISTORY_MAX}. */
540
+ function recordHistory(state: ChemEnumDialogState): void {
541
+ const entry = buildHistoryEntry(state);
542
+ const prev = readHistory();
543
+ writeHistory([entry, ...prev].slice(0, HISTORY_MAX));
544
+ }
545
+
546
+ /** Reparses SMILES through `makeCore` / `makeRGroup` so validation runs with the current rdkit. */
547
+ function applyHistoryEntry(entry: ChemEnumHistoryEntry, state: ChemEnumDialogState, rdkit: RDModule): void {
548
+ state.cores = entry.cores.map((smi) => makeCore(smi, '', rdkit));
549
+ state.rGroupsByNum = new Map();
550
+ for (const [nStr, list] of Object.entries(entry.rGroups ?? {})) {
551
+ const num = parseInt(nStr, 10);
552
+ if (!Number.isFinite(num)) continue;
553
+ state.rGroupsByNum.set(num, list.map((smi) => makeRGroup(smi, num, '', rdkit)));
554
+ }
555
+ if (entry.mode === ChemEnumModes.Zip || entry.mode === ChemEnumModes.Cartesian)
556
+ state.mode = entry.mode;
557
+ }
558
+
559
+ function formatHistoryDate(iso: string): string {
560
+ try {
561
+ const d = new Date(iso);
562
+ return `${d.toLocaleDateString()} ${d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}`;
563
+ } catch {
564
+ return iso;
565
+ }
566
+ }
567
+
568
+ /** Pops a context menu listing history entries. `onPick` applies + refreshes. */
569
+ function showHistoryMenu(onPick: (entry: ChemEnumHistoryEntry) => void): void {
570
+ const entries = readHistory();
571
+ const menu = DG.Menu.popup();
572
+ if (entries.length === 0) {
573
+ menu.item('No entries', () => {});
574
+ } else {
575
+ for (const entry of entries) {
576
+ const label = `${formatHistoryDate(entry.date)} — ${entry.summary}`;
577
+ menu.item(label, () => onPick(entry));
578
+ }
579
+ }
580
+ menu.show();
581
+ }
582
+
583
+ // ─── CSV preload (app mode only, when history is empty) ─────────────────────
584
+
585
+ /**
586
+ * Seeds `state` from the shipped `files/enumeration/{cores,rgroups}.csv`. The cores CSV has a
587
+ * single "Core" column; the R-groups CSV has one column per R-number (`R1`, `R2`, …).
588
+ * Returns whether anything was loaded.
589
+ */
590
+ async function preloadFromFiles(state: ChemEnumDialogState, rdkit: RDModule): Promise<boolean> {
591
+ try {
592
+ const coresText = await _package.files.readAsText('enumeration/chem_enum_cores.csv');
593
+ const coresDf = DG.DataFrame.fromCsv(coresText);
594
+ const coreCol = coresDf.col('Core') ?? coresDf.columns.byIndex(0);
595
+ if (coreCol) {
596
+ for (let i = 0; i < coreCol.length; i++) {
597
+ if (coreCol.isNone(i)) continue;
598
+ const v = String(coreCol.get(i) ?? '').trim();
599
+ if (!v) continue;
600
+ state.cores.push(makeCore(v, '', rdkit));
601
+ }
602
+ }
603
+
604
+ const rgText = await _package.files.readAsText('enumeration/chem_enum_rgroups.csv');
605
+ const rgDf = DG.DataFrame.fromCsv(rgText);
606
+ for (const col of rgDf.columns.toList()) {
607
+ const m = col.name.match(/^r\s*(\d+)$/i);
608
+ if (!m) continue;
609
+ const num = parseInt(m[1], 10);
610
+ const list: ChemEnumRGroup[] = [];
611
+ for (let i = 0; i < col.length; i++) {
612
+ if (col.isNone(i)) continue;
613
+ const v = String(col.get(i) ?? '').trim();
614
+ if (!v) continue;
615
+ list.push(makeRGroup(v, num, '', rdkit));
616
+ }
617
+ if (list.length > 0) state.rGroupsByNum.set(num, list);
618
+ }
619
+ return state.cores.length > 0 || state.rGroupsByNum.size > 0;
620
+ } catch {
621
+ return false;
622
+ }
623
+ }
624
+
481
625
  /** Extracts a molfile string from a cell whose column is of MOLECULE semtype. */
482
626
  async function cellToMolfile(cell: DG.Cell): Promise<string | null> {
483
627
  try {
@@ -522,9 +666,22 @@ export async function polyToolEnumerateChemUI(cell?: DG.Cell): Promise<void> {
522
666
  const panel = buildChemEnumPanel(rdkit, preloadCore);
523
667
  const dialog = ui.dialog({title: DIALOG_TITLE})
524
668
  .add(panel.root)
669
+ .addButton('History', () => {
670
+ showHistoryMenu((entry) => {
671
+ applyHistoryEntry(entry, panel.state, rdkit);
672
+ panel.refresh();
673
+ });
674
+ })
525
675
  .onOK(async () => { await panel.execute(); });
526
676
  panel.bindActionButton(dialog.getButton('OK') as HTMLButtonElement);
527
677
  dialog.show({resizable: true, width: 960});
678
+ const historyButton = dialog.getButton('History') as HTMLButtonElement;
679
+ if (historyButton) {
680
+ historyButton.style.order = '1';
681
+ historyButton.style.marginRight = 'auto';
682
+ ui.tooltip.bind(historyButton, 'View and apply past enumerations');
683
+ historyButton.innerHTML = ui.iconFA('history', () => {}).outerHTML;
684
+ }
528
685
  } catch (err: any) {
529
686
  defaultErrorHandler(err);
530
687
  }
@@ -535,15 +692,29 @@ export async function polyToolEnumerateChemApp(): Promise<DG.View | null> {
535
692
  await _package.initPromise;
536
693
  try {
537
694
  const rdkit = await getRdKitModule();
538
- const panel = buildChemEnumPanel(rdkit, null);
539
- const runBtn = ui.bigButton('Enumerate', async () => { await panel.execute(); });
695
+ const panel = buildChemEnumPanel(rdkit, null, 'app');
696
+ const runBtn = ui.button('Enumerate', async () => { await panel.execute(); });
540
697
  panel.bindActionButton(runBtn as HTMLButtonElement);
698
+ panel.appActionHost?.appendChild(runBtn);
699
+
700
+ // Seed the panel: most-recent history wins; if there is none, fall back to the shipped
701
+ // demo CSVs so the app never opens to an empty screen. Both paths mutate panel.state and
702
+ // panel.refresh() picks the changes up.
703
+ const history = readHistory();
704
+ if (history.length > 0) {
705
+ applyHistoryEntry(history[0], panel.state, rdkit);
706
+ panel.refresh();
707
+ } else {
708
+ preloadFromFiles(panel.state, rdkit).then((loaded) => {
709
+ if (loaded) panel.refresh();
710
+ });
711
+ }
541
712
 
542
713
  const view = DG.View.create();
543
714
  view.name = DIALOG_TITLE;
544
715
  view.box = true;
545
- view.root.appendChild(ui.divV([panel.root, ui.div([runBtn], {style: {padding: '8px 4px 0'}})],
546
- {style: {height: '100%', width: '100%', padding: '8px'}}));
716
+ view.root.appendChild(ui.div([panel.root],
717
+ {style: {height: '100%', width: '100%', padding: '8px', boxSizing: 'border-box'}}));
547
718
  return view;
548
719
  } catch (err: any) {
549
720
  defaultErrorHandler(err);
@@ -557,9 +728,20 @@ interface ChemEnumPanel {
557
728
  execute: () => Promise<void>;
558
729
  /** Bind the action (OK / Enumerate) button so it tracks validation state. */
559
730
  bindActionButton: (btn: HTMLButtonElement) => void;
731
+ /** Re-render after mutating `state` from outside (e.g. applying a history entry). */
732
+ refresh: () => void;
733
+ /**
734
+ * Bottom-right "controls" cell host for the caller's Enumerate button.
735
+ * Only populated in app layout; undefined in dialog layout.
736
+ */
737
+ appActionHost?: HTMLElement;
560
738
  }
561
739
 
562
- function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null): ChemEnumPanel {
740
+ type ChemEnumLayout = 'dialog' | 'app';
741
+
742
+ function buildChemEnumPanel(
743
+ rdkit: RDModule, preloadCore: ChemEnumCore | null, layout: ChemEnumLayout = 'dialog',
744
+ ): ChemEnumPanel {
563
745
  const state: ChemEnumDialogState = {
564
746
  cores: preloadCore ? [preloadCore] : [],
565
747
  rGroupsByNum: new Map(),
@@ -567,10 +749,14 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
567
749
  appendToTable: null,
568
750
  };
569
751
 
570
- // ── Cores: single-row horizontal virtualView ───────────────────────────────
752
+ // ── Cores: single-row horizontal virtualView (dialog) or wrapped flow (app) ─
571
753
  const ROW_H = CARD_H + 16; // card height + horizontal scrollbar breathing room
572
754
  const coresEmpty = ui.divText('No cores — draw or import at least one.', {style: {color: 'var(--grey-4)', padding: '20px 12px', fontSize: '12px'}});
573
- const coresVvHost = ui.div([], {style: {width: '100%', height: `${ROW_H}px`, overflow: 'hidden'}});
755
+ // App layout puts cores into the top-left grid cell — fill height, scroll vertically as cards wrap.
756
+ // Dialog layout keeps the original single-row horizontal scroller.
757
+ const coresVvHost = layout === 'app' ?
758
+ ui.div([], {style: {width: '100%', flex: '1 1 auto', minHeight: '0', overflowY: 'auto', overflowX: 'hidden'}}) :
759
+ ui.div([], {style: {width: '100%', height: `${ROW_H}px`, overflow: 'hidden'}});
574
760
  let coresVv: DG.VirtualView | null = null;
575
761
 
576
762
  const coresRenderer = (i: number): HTMLElement => {
@@ -604,6 +790,20 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
604
790
  }
605
791
  coresVvHost.style.display = 'block';
606
792
  coresEmpty.style.display = 'none';
793
+ if (layout === 'app') {
794
+ // Cores are realistically dozens at most — render all directly in a wrapped flow so
795
+ // the top-left cell uses its full area instead of constraining cards to one row.
796
+ ui.empty(coresVvHost);
797
+ coresVv = null;
798
+ const wrap = ui.div([], {style: {
799
+ display: 'flex', flexWrap: 'wrap', gap: '4px',
800
+ width: '100%', alignContent: 'flex-start', padding: '2px 0',
801
+ }});
802
+ for (let i = 0; i < state.cores.length; i++)
803
+ wrap.appendChild(coresRenderer(i));
804
+ coresVvHost.appendChild(wrap);
805
+ return;
806
+ }
607
807
  if (!coresVv) {
608
808
  coresVv = ui.virtualView(state.cores.length, coresRenderer, false, 1);
609
809
  applyHorizontalRowStyle(coresVv.root);
@@ -701,9 +901,14 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
701
901
 
702
902
  // ── Preview (up to 12 random samples, wrapped flex) ───────────────────────
703
903
  const PREVIEW_COUNT = 12;
704
- const previewHost = ui.div([], {style: {
904
+ // Dialog caps preview height at 450px (fits next to the cores/r-groups column).
905
+ // App layout drops the cap so preview fills its bottom-left cell.
906
+ const previewHost = ui.div([], {style: layout === 'app' ? {
907
+ width: '100%', height: '100%', display: 'flex', flexWrap: 'wrap', gap: '6px',
908
+ alignContent: 'flex-start', padding: '2px 0', flex: '1 1 auto', minHeight: '0', overflow: 'auto',
909
+ } : {
705
910
  width: '100%', display: 'flex', flexWrap: 'wrap', gap: '6px',
706
- alignContent: 'flex-start', padding: '2px 0', maxHeight: '450px', overflow: 'scroll'
911
+ alignContent: 'flex-start', padding: '2px 0', maxHeight: '450px', overflow: 'scroll',
707
912
  }});
708
913
 
709
914
  const redrawPreview = () => {
@@ -848,69 +1053,169 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
848
1053
  minHeight: '150px', padding: '4px 0',
849
1054
  } as const;
850
1055
 
851
- // ── Layout: horizontal split — cores + r-groups (left 60%) | preview (right 40%) ──
1056
+ // ── Section headers (shared between layouts) ──────────────────────────────
852
1057
  const coresHeader = sectionHeader(
853
1058
  'Cores', addCoreFromSketcher, addCoresFromImport,
854
1059
  () => { state.cores.splice(0, state.cores.length); refresh(); },
855
1060
  );
856
1061
  coresClearBtn = coresHeader.clearBtn;
857
- const coresSection = ui.divV([
858
- coresHeader.root,
859
- ui.div([coresVvHost, coresEmpty], {style: {padding: '0'}}),
860
- ], {style: sectionStyle});
861
1062
 
862
1063
  const rGroupsHeader = sectionHeader(
863
1064
  'R-Groups', addRGroupFromSketcher, addRGroupsFromImport,
864
1065
  () => { state.rGroupsByNum.clear(); refresh(); },
865
1066
  );
866
1067
  rGroupsClearBtn = rGroupsHeader.clearBtn;
867
- const rGroupsSection = ui.divV([
868
- rGroupsHeader.root,
869
- rGroupsHost,
870
- ], {style: {
871
- display: 'flex', flexDirection: 'column',
872
- maxHeight: '300px', padding: '4px 0',
873
- }});
874
1068
 
875
- const previewSection = ui.divV([
876
- sectionHeader(`Preview`).root,
877
- previewHost,
878
- ], {style: {
879
- ...sectionStyle,
880
- width: '100%', height: '100%',
881
- overflowY: 'auto', overflowX: 'hidden',
882
- }});
1069
+ let body: HTMLElement;
1070
+ let appActionHost: HTMLElement | undefined;
1071
+
1072
+ if (layout === 'app') {
1073
+ // ── App: two flex columns. Left = cores + preview. Right = r-groups (grows) + controls (compact). ──
1074
+ const cellBaseStyle = {
1075
+ display: 'flex', flexDirection: 'column',
1076
+ minHeight: '0', minWidth: '0',
1077
+ padding: '8px', boxSizing: 'border-box',
1078
+ border: '1px solid var(--grey-2)', borderRadius: '4px',
1079
+ background: 'var(--white)', overflow: 'hidden',
1080
+ } as const;
1081
+ const growCellStyle = {...cellBaseStyle, flex: '1 1 0'} as const;
1082
+
1083
+ countText.style.fontSize = '11px';
1084
+ countText.style.color = 'var(--grey-5)';
1085
+
1086
+ const coresCell = ui.divV([
1087
+ coresHeader.root,
1088
+ coresVvHost,
1089
+ coresEmpty,
1090
+ ], {style: growCellStyle});
1091
+
1092
+ const rGroupsCell = ui.divV([
1093
+ rGroupsHeader.root,
1094
+ rGroupsHost,
1095
+ ], {style: growCellStyle});
1096
+
1097
+ const previewCell = ui.divV([
1098
+ sectionHeader('Preview').root,
1099
+ previewHost,
1100
+ ], {style: growCellStyle});
883
1101
 
884
- const leftColumn = ui.divV([coresSection, rGroupsSection], {style: {
885
- flex: '0 0 60%', width: '60%',
886
- display: 'flex', flexDirection: 'column', gap: '4px',
887
- paddingRight: '8px', borderRight: '1px solid var(--grey-2)',
888
- }});
1102
+ // Run header carries the live status — count + issues — inline on the right.
1103
+ const runLabel = ui.divText('Run', {style: {
1104
+ fontWeight: '600', fontSize: '12px', color: 'var(--grey-6)', alignSelf: 'center', minWidth: '40px',
1105
+ }});
1106
+ const runHeaderStats = ui.divH([countText, errorBadge.root], {style: {
1107
+ alignItems: 'center', gap: '8px', marginLeft: 'auto',
1108
+ }});
1109
+ const runHeader = ui.divH([runLabel, runHeaderStats], {style: {
1110
+ alignItems: 'center', gap: '8px', margin: '0 0 6px',
1111
+ flex: '0 0 auto', width: '100%',
1112
+ }});
889
1113
 
890
- const rightColumn = ui.divV([previewSection], {style: {
891
- flex: '0 0 40%', width: '40%',
892
- display: 'flex', flexDirection: 'column',
893
- paddingLeft: '8px',
894
- }});
1114
+ // History icon — sits next to Enumerate. Reads localStorage on click and pops a menu.
1115
+ const historyBtn = ui.iconFA('history', () => {
1116
+ showHistoryMenu((entry) => {
1117
+ applyHistoryEntry(entry, state, rdkit);
1118
+ refresh();
1119
+ });
1120
+ }, 'Show recent enumerations');
1121
+ historyBtn.style.fontSize = '16px';
1122
+ historyBtn.style.padding = '4px 6px';
1123
+ historyBtn.style.cursor = 'pointer';
1124
+ historyBtn.style.color = 'var(--blue-3)';
1125
+
1126
+ const clearAllBtn = ui.button('Clear all', () => {
1127
+ state.cores.splice(0, state.cores.length);
1128
+ state.rGroupsByNum.clear();
1129
+ refresh();
1130
+ });
1131
+ ui.tooltip.bind(clearAllBtn, 'Remove all cores and R-groups');
895
1132
 
896
- const split = ui.divH([leftColumn, rightColumn], {style: {
897
- width: '100%', alignItems: 'stretch', gap: '0',
898
- }});
1133
+ appActionHost = ui.div([], {style: {flex: '0 0 auto'}});
1134
+ const actionRow = ui.divH([historyBtn, appActionHost], {style: {
1135
+ alignItems: 'center', gap: '8px', marginTop: '8px', flex: '0 0 auto',
1136
+ }});
899
1137
 
900
- const statusLine = ui.divH([
901
- modeInput.root,
902
- countText,
903
- errorBadge.root,
904
- ], {style: {alignItems: 'center', gap: '16px', padding: '4px 0'}});
1138
+ const controlsCell = ui.divV([
1139
+ runHeader,
1140
+ modeInput.root,
1141
+ appendToTableInput.root,
1142
+ clearAllBtn,
1143
+ actionRow,
1144
+ ], {style: {
1145
+ ...cellBaseStyle, flex: '0 0 auto', overflow: 'visible',
1146
+ }});
905
1147
 
906
- const footer = ui.div([appendToTableInput.root], {style: {padding: '2px 0'}});
1148
+ const leftColumn = ui.divV([coresCell, previewCell], {style: {
1149
+ flex: '1 1 50%', minWidth: '0',
1150
+ display: 'flex', flexDirection: 'column', gap: '8px',
1151
+ }});
907
1152
 
908
- const body = ui.divV([
909
- split, statusLine, footer,
910
- ], {style: {
911
- width: '100%', padding: '4px',
912
- display: 'flex', flexDirection: 'column', gap: '4px',
913
- }});
1153
+ const rightColumn = ui.divV([rGroupsCell, controlsCell], {style: {
1154
+ flex: '1 1 50%', minWidth: '0',
1155
+ display: 'flex', flexDirection: 'column', gap: '8px',
1156
+ }});
1157
+
1158
+ body = ui.divH([leftColumn, rightColumn], {style: {
1159
+ width: '100%', height: '100%',
1160
+ display: 'flex', flexDirection: 'row',
1161
+ gap: '8px',
1162
+ padding: '4px', boxSizing: 'border-box',
1163
+ }});
1164
+ } else {
1165
+ // ── Dialog: horizontal split — cores + r-groups (left 60%) | preview (right 40%) ──
1166
+ const coresSection = ui.divV([
1167
+ coresHeader.root,
1168
+ ui.div([coresVvHost, coresEmpty], {style: {padding: '0'}}),
1169
+ ], {style: sectionStyle});
1170
+
1171
+ const rGroupsSection = ui.divV([
1172
+ rGroupsHeader.root,
1173
+ rGroupsHost,
1174
+ ], {style: {
1175
+ display: 'flex', flexDirection: 'column',
1176
+ maxHeight: '300px', padding: '4px 0',
1177
+ }});
1178
+
1179
+ const previewSection = ui.divV([
1180
+ sectionHeader(`Preview`).root,
1181
+ previewHost,
1182
+ ], {style: {
1183
+ ...sectionStyle,
1184
+ width: '100%', height: '100%',
1185
+ overflowY: 'auto', overflowX: 'hidden',
1186
+ }});
1187
+
1188
+ const leftColumn = ui.divV([coresSection, rGroupsSection], {style: {
1189
+ flex: '0 0 60%', width: '60%',
1190
+ display: 'flex', flexDirection: 'column', gap: '4px',
1191
+ paddingRight: '8px', borderRight: '1px solid var(--grey-2)',
1192
+ }});
1193
+
1194
+ const rightColumn = ui.divV([previewSection], {style: {
1195
+ flex: '0 0 40%', width: '40%',
1196
+ display: 'flex', flexDirection: 'column',
1197
+ paddingLeft: '8px',
1198
+ }});
1199
+
1200
+ const split = ui.divH([leftColumn, rightColumn], {style: {
1201
+ width: '100%', alignItems: 'stretch', gap: '0',
1202
+ }});
1203
+
1204
+ const statusLine = ui.divH([
1205
+ modeInput.root,
1206
+ countText,
1207
+ errorBadge.root,
1208
+ ], {style: {alignItems: 'center', gap: '16px', padding: '4px 0'}});
1209
+
1210
+ const footer = ui.div([appendToTableInput.root], {style: {padding: '2px 0'}});
1211
+
1212
+ body = ui.divV([
1213
+ split, statusLine, footer,
1214
+ ], {style: {
1215
+ width: '100%', padding: '4px',
1216
+ display: 'flex', flexDirection: 'column', gap: '4px',
1217
+ }});
1218
+ }
914
1219
 
915
1220
  refresh();
916
1221
  return {
@@ -918,6 +1223,8 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
918
1223
  state,
919
1224
  execute: () => executeEnumeration(state, rdkit),
920
1225
  bindActionButton: (btn) => { okButton = btn; refresh(); },
1226
+ refresh,
1227
+ appActionHost,
921
1228
  };
922
1229
  }
923
1230
 
@@ -932,6 +1239,10 @@ async function executeEnumeration(state: ChemEnumDialogState, _rdkit: RDModule):
932
1239
  if (!results) { grok.shell.warning('Enumeration failed — check validation messages in the dialog.'); return; }
933
1240
  if (results.length === 0) { grok.shell.warning('No molecules produced.'); return; }
934
1241
 
1242
+ // Record this configuration in history before any post-processing — if the user closes
1243
+ // mid-canonicalization, they can still recover the inputs that produced something.
1244
+ recordHistory(state);
1245
+
935
1246
  const rNumbersUsed = new Set<number>();
936
1247
  for (const r of results) for (const n of r.rGroupSmilesByNum.keys()) rNumbersUsed.add(n);
937
1248
  const sortedRs = [...rNumbersUsed].sort((a, b) => a - b);