@datagrok/sequence-translator 1.10.19 → 1.10.21

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';
@@ -18,12 +19,13 @@ import {
18
19
  extractRNumbers,
19
20
  makeCore,
20
21
  makeRGroup,
22
+ normalizeRLabels,
21
23
  validateParams,
22
24
  } from './pt-chem-enum';
23
25
  import {_package} from '../package';
24
26
  import {defaultErrorHandler} from '../utils/err-info';
25
27
 
26
- const DIALOG_TITLE = 'PolyTool Chem Enumeration';
28
+ const DIALOG_TITLE = 'Markush Enumerator';
27
29
 
28
30
  const MODE_TOOLTIPS: Record<ChemEnumMode, string> = {
29
31
  [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.',
@@ -49,11 +51,17 @@ interface CardOpts {
49
51
  onRemove?: () => void;
50
52
  }
51
53
 
54
+ // Popular multi symbol single atoms for quick lookup in card builder
55
+ const SINGLE_ATOM_SYMBOLS_LOOKUP = new Set([
56
+ 'Cl', 'Br', 'Al', 'Si', 'Li', 'Na', 'Mg', 'Ca', 'Ti', 'At', 'Fe', 'Co', 'Ni', 'Cu', 'Zn', 'Ga', 'Ge', 'As', 'Se', 'Kr', 'Rb',
57
+ 'Au', 'Ag', 'Pt', 'Pb', 'Sn', 'Sb', 'Te', 'Xe', 'Cs', 'Ba', 'La', 'Ce', 'Pr', 'Nd', 'Pm', 'Sm', 'Eu', 'Gd', 'Tb', 'Dy', 'Ho']);
58
+
52
59
  /** Draws a molecule into a fixed-size host, constraining SVG dimensions. */
53
60
  function drawMolInto(host: HTMLElement, smi: string, w: number, h: number): void {
54
61
  ui.empty(host);
55
62
  try {
56
- const el = grok.chem.drawMolecule(smi, w, h);
63
+ const correctedSmi = smi.length === 1 || SINGLE_ATOM_SYMBOLS_LOOKUP.has(smi) ? `[${smi}]` : smi;
64
+ const el = grok.chem.drawMolecule(correctedSmi, w, h);
57
65
  el.style.width = `${w}px`;
58
66
  el.style.height = `${h}px`;
59
67
  el.style.maxWidth = `${w}px`;
@@ -71,7 +79,8 @@ function buildCard(opts: CardOpts): HTMLElement {
71
79
  display: 'flex', alignItems: 'center', justifyContent: 'center',
72
80
  background: 'transparent', overflow: 'hidden', flex: '0 0 auto',
73
81
  }});
74
- if (opts.smiles && !opts.error) drawMolInto(thumbHost, opts.smiles, THUMB_W, THUMB_H);
82
+ if (opts.smiles && !opts.error)
83
+ drawMolInto(thumbHost, opts.smiles, THUMB_W, THUMB_H);
75
84
  else thumbHost.appendChild(ui.divText('—', {style: {color: 'var(--grey-4)'}}));
76
85
 
77
86
  const subtitleEl = ui.divText(opts.subtitle, {style: {
@@ -478,6 +487,149 @@ interface ChemEnumDialogState {
478
487
  appendToTable: DG.DataFrame | null;
479
488
  }
480
489
 
490
+ // ─── History (localStorage-backed, shared by dialog + app) ──────────────────
491
+
492
+ const HISTORY_KEY = 'd4-markush-enumeration-history';
493
+ const HISTORY_MAX = 10;
494
+
495
+ interface ChemEnumHistoryEntry {
496
+ /** ISO timestamp of when the enumeration was recorded. */
497
+ date: string;
498
+ /** One-line summary: `"6 cores · R1:6 · R2:4"`. */
499
+ summary: string;
500
+ mode: ChemEnumMode;
501
+ /** Original (unnormalized) SMILES for each core. */
502
+ cores: string[];
503
+ /** R-number → list of original R-group SMILES. JSON object so we can round-trip. */
504
+ rGroups: { [rNumber: string]: string[] };
505
+ }
506
+
507
+ function readHistory(): ChemEnumHistoryEntry[] {
508
+ try {
509
+ const raw = localStorage.getItem(HISTORY_KEY);
510
+ if (!raw) return [];
511
+ const arr = JSON.parse(raw);
512
+ return Array.isArray(arr) ? arr : [];
513
+ } catch {
514
+ return [];
515
+ }
516
+ }
517
+
518
+ function writeHistory(entries: ChemEnumHistoryEntry[]): void {
519
+ try {
520
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(entries));
521
+ } catch {
522
+ // Quota exceeded or storage unavailable — silently skip; history is best-effort.
523
+ }
524
+ }
525
+
526
+ function summarizeState(state: ChemEnumDialogState): string {
527
+ const partsR: string[] = [];
528
+ const sortedNums = [...state.rGroupsByNum.keys()].sort((a, b) => a - b);
529
+ for (const n of sortedNums) partsR.push(`R${n}:${state.rGroupsByNum.get(n)!.length}`);
530
+ return `${state.cores.length} core${state.cores.length === 1 ? '' : 's'}` +
531
+ (partsR.length ? ' · ' + partsR.join(' · ') : '');
532
+ }
533
+
534
+ function buildHistoryEntry(state: ChemEnumDialogState): ChemEnumHistoryEntry {
535
+ const rGroups: { [n: string]: string[] } = {};
536
+ for (const [n, list] of state.rGroupsByNum)
537
+ rGroups[String(n)] = list.map((rg) => rg.originalSmiles);
538
+ return {
539
+ date: new Date().toISOString(),
540
+ summary: summarizeState(state),
541
+ mode: state.mode,
542
+ cores: state.cores.map((c) => c.originalSmiles),
543
+ rGroups,
544
+ };
545
+ }
546
+
547
+ /** Prepends `state` as a new entry, keeps at most {@link HISTORY_MAX}. */
548
+ function recordHistory(state: ChemEnumDialogState): void {
549
+ const entry = buildHistoryEntry(state);
550
+ const prev = readHistory();
551
+ writeHistory([entry, ...prev].slice(0, HISTORY_MAX));
552
+ }
553
+
554
+ /** Reparses SMILES through `makeCore` / `makeRGroup` so validation runs with the current rdkit. */
555
+ function applyHistoryEntry(entry: ChemEnumHistoryEntry, state: ChemEnumDialogState, rdkit: RDModule): void {
556
+ state.cores = entry.cores.map((smi) => makeCore(smi, '', rdkit));
557
+ state.rGroupsByNum = new Map();
558
+ for (const [nStr, list] of Object.entries(entry.rGroups ?? {})) {
559
+ const num = parseInt(nStr, 10);
560
+ if (!Number.isFinite(num)) continue;
561
+ state.rGroupsByNum.set(num, list.map((smi) => makeRGroup(smi, num, '', rdkit)));
562
+ }
563
+ if (entry.mode === ChemEnumModes.Zip || entry.mode === ChemEnumModes.Cartesian)
564
+ state.mode = entry.mode;
565
+ }
566
+
567
+ function formatHistoryDate(iso: string): string {
568
+ try {
569
+ const d = new Date(iso);
570
+ return `${d.toLocaleDateString()} ${d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}`;
571
+ } catch {
572
+ return iso;
573
+ }
574
+ }
575
+
576
+ /** Pops a context menu listing history entries. `onPick` applies + refreshes. */
577
+ function showHistoryMenu(onPick: (entry: ChemEnumHistoryEntry) => void): void {
578
+ const entries = readHistory();
579
+ const menu = DG.Menu.popup();
580
+ if (entries.length === 0) {
581
+ menu.item('No entries', () => {});
582
+ } else {
583
+ for (const entry of entries) {
584
+ const label = `${formatHistoryDate(entry.date)} — ${entry.summary}`;
585
+ menu.item(label, () => onPick(entry));
586
+ }
587
+ }
588
+ menu.show();
589
+ }
590
+
591
+ // ─── CSV preload (app mode only, when history is empty) ─────────────────────
592
+
593
+ /**
594
+ * Seeds `state` from the shipped `files/enumeration/{cores,rgroups}.csv`. The cores CSV has a
595
+ * single "Core" column; the R-groups CSV has one column per R-number (`R1`, `R2`, …).
596
+ * Returns whether anything was loaded.
597
+ */
598
+ async function preloadFromFiles(state: ChemEnumDialogState, rdkit: RDModule): Promise<boolean> {
599
+ try {
600
+ const coresText = await _package.files.readAsText('enumeration/chem_enum_cores.csv');
601
+ const coresDf = DG.DataFrame.fromCsv(coresText);
602
+ const coreCol = coresDf.col('Core') ?? coresDf.columns.byIndex(0);
603
+ if (coreCol) {
604
+ for (let i = 0; i < coreCol.length; i++) {
605
+ if (coreCol.isNone(i)) continue;
606
+ const v = String(coreCol.get(i) ?? '').trim();
607
+ if (!v) continue;
608
+ state.cores.push(makeCore(v, '', rdkit));
609
+ }
610
+ }
611
+
612
+ const rgText = await _package.files.readAsText('enumeration/chem_enum_rgroups.csv');
613
+ const rgDf = DG.DataFrame.fromCsv(rgText);
614
+ for (const col of rgDf.columns.toList()) {
615
+ const m = col.name.match(/^r\s*(\d+)$/i);
616
+ if (!m) continue;
617
+ const num = parseInt(m[1], 10);
618
+ const list: ChemEnumRGroup[] = [];
619
+ for (let i = 0; i < col.length; i++) {
620
+ if (col.isNone(i)) continue;
621
+ const v = String(col.get(i) ?? '').trim();
622
+ if (!v) continue;
623
+ list.push(makeRGroup(v, num, '', rdkit));
624
+ }
625
+ if (list.length > 0) state.rGroupsByNum.set(num, list);
626
+ }
627
+ return state.cores.length > 0 || state.rGroupsByNum.size > 0;
628
+ } catch {
629
+ return false;
630
+ }
631
+ }
632
+
481
633
  /** Extracts a molfile string from a cell whose column is of MOLECULE semtype. */
482
634
  async function cellToMolfile(cell: DG.Cell): Promise<string | null> {
483
635
  try {
@@ -522,9 +674,22 @@ export async function polyToolEnumerateChemUI(cell?: DG.Cell): Promise<void> {
522
674
  const panel = buildChemEnumPanel(rdkit, preloadCore);
523
675
  const dialog = ui.dialog({title: DIALOG_TITLE})
524
676
  .add(panel.root)
677
+ .addButton('History', () => {
678
+ showHistoryMenu((entry) => {
679
+ applyHistoryEntry(entry, panel.state, rdkit);
680
+ panel.refresh();
681
+ });
682
+ })
525
683
  .onOK(async () => { await panel.execute(); });
526
684
  panel.bindActionButton(dialog.getButton('OK') as HTMLButtonElement);
527
685
  dialog.show({resizable: true, width: 960});
686
+ const historyButton = dialog.getButton('History') as HTMLButtonElement;
687
+ if (historyButton) {
688
+ historyButton.style.order = '1';
689
+ historyButton.style.marginRight = 'auto';
690
+ ui.tooltip.bind(historyButton, 'View and apply past enumerations');
691
+ historyButton.innerHTML = ui.iconFA('history', () => {}).outerHTML;
692
+ }
528
693
  } catch (err: any) {
529
694
  defaultErrorHandler(err);
530
695
  }
@@ -535,15 +700,29 @@ export async function polyToolEnumerateChemApp(): Promise<DG.View | null> {
535
700
  await _package.initPromise;
536
701
  try {
537
702
  const rdkit = await getRdKitModule();
538
- const panel = buildChemEnumPanel(rdkit, null);
539
- const runBtn = ui.bigButton('Enumerate', async () => { await panel.execute(); });
703
+ const panel = buildChemEnumPanel(rdkit, null, 'app');
704
+ const runBtn = ui.button('Enumerate', async () => { await panel.execute(); });
540
705
  panel.bindActionButton(runBtn as HTMLButtonElement);
706
+ panel.appActionHost?.appendChild(runBtn);
707
+
708
+ // Seed the panel: most-recent history wins; if there is none, fall back to the shipped
709
+ // demo CSVs so the app never opens to an empty screen. Both paths mutate panel.state and
710
+ // panel.refresh() picks the changes up.
711
+ const history = readHistory();
712
+ if (history.length > 0) {
713
+ applyHistoryEntry(history[0], panel.state, rdkit);
714
+ panel.refresh();
715
+ } else {
716
+ preloadFromFiles(panel.state, rdkit).then((loaded) => {
717
+ if (loaded) panel.refresh();
718
+ });
719
+ }
541
720
 
542
721
  const view = DG.View.create();
543
722
  view.name = DIALOG_TITLE;
544
723
  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'}}));
724
+ view.root.appendChild(ui.div([panel.root],
725
+ {style: {height: '100%', width: '100%', padding: '8px', boxSizing: 'border-box'}}));
547
726
  return view;
548
727
  } catch (err: any) {
549
728
  defaultErrorHandler(err);
@@ -557,9 +736,20 @@ interface ChemEnumPanel {
557
736
  execute: () => Promise<void>;
558
737
  /** Bind the action (OK / Enumerate) button so it tracks validation state. */
559
738
  bindActionButton: (btn: HTMLButtonElement) => void;
739
+ /** Re-render after mutating `state` from outside (e.g. applying a history entry). */
740
+ refresh: () => void;
741
+ /**
742
+ * Bottom-right "controls" cell host for the caller's Enumerate button.
743
+ * Only populated in app layout; undefined in dialog layout.
744
+ */
745
+ appActionHost?: HTMLElement;
560
746
  }
561
747
 
562
- function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null): ChemEnumPanel {
748
+ type ChemEnumLayout = 'dialog' | 'app';
749
+
750
+ function buildChemEnumPanel(
751
+ rdkit: RDModule, preloadCore: ChemEnumCore | null, layout: ChemEnumLayout = 'dialog',
752
+ ): ChemEnumPanel {
563
753
  const state: ChemEnumDialogState = {
564
754
  cores: preloadCore ? [preloadCore] : [],
565
755
  rGroupsByNum: new Map(),
@@ -567,10 +757,14 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
567
757
  appendToTable: null,
568
758
  };
569
759
 
570
- // ── Cores: single-row horizontal virtualView ───────────────────────────────
760
+ // ── Cores: single-row horizontal virtualView (dialog) or wrapped flow (app) ─
571
761
  const ROW_H = CARD_H + 16; // card height + horizontal scrollbar breathing room
572
762
  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'}});
763
+ // App layout puts cores into the top-left grid cell — fill height, scroll vertically as cards wrap.
764
+ // Dialog layout keeps the original single-row horizontal scroller.
765
+ const coresVvHost = layout === 'app' ?
766
+ ui.div([], {style: {width: '100%', flex: '1 1 auto', minHeight: '0', overflowY: 'auto', overflowX: 'hidden'}}) :
767
+ ui.div([], {style: {width: '100%', height: `${ROW_H}px`, overflow: 'hidden'}});
574
768
  let coresVv: DG.VirtualView | null = null;
575
769
 
576
770
  const coresRenderer = (i: number): HTMLElement => {
@@ -604,6 +798,20 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
604
798
  }
605
799
  coresVvHost.style.display = 'block';
606
800
  coresEmpty.style.display = 'none';
801
+ if (layout === 'app') {
802
+ // Cores are realistically dozens at most — render all directly in a wrapped flow so
803
+ // the top-left cell uses its full area instead of constraining cards to one row.
804
+ ui.empty(coresVvHost);
805
+ coresVv = null;
806
+ const wrap = ui.div([], {style: {
807
+ display: 'flex', flexWrap: 'wrap', gap: '4px',
808
+ width: '100%', alignContent: 'flex-start', padding: '2px 0',
809
+ }});
810
+ for (let i = 0; i < state.cores.length; i++)
811
+ wrap.appendChild(coresRenderer(i));
812
+ coresVvHost.appendChild(wrap);
813
+ return;
814
+ }
607
815
  if (!coresVv) {
608
816
  coresVv = ui.virtualView(state.cores.length, coresRenderer, false, 1);
609
817
  applyHorizontalRowStyle(coresVv.root);
@@ -701,9 +909,14 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
701
909
 
702
910
  // ── Preview (up to 12 random samples, wrapped flex) ───────────────────────
703
911
  const PREVIEW_COUNT = 12;
704
- const previewHost = ui.div([], {style: {
912
+ // Dialog caps preview height at 450px (fits next to the cores/r-groups column).
913
+ // App layout drops the cap so preview fills its bottom-left cell.
914
+ const previewHost = ui.div([], {style: layout === 'app' ? {
915
+ width: '100%', height: '100%', display: 'flex', flexWrap: 'wrap', gap: '6px',
916
+ alignContent: 'flex-start', padding: '2px 0', flex: '1 1 auto', minHeight: '0', overflow: 'auto',
917
+ } : {
705
918
  width: '100%', display: 'flex', flexWrap: 'wrap', gap: '6px',
706
- alignContent: 'flex-start', padding: '2px 0', maxHeight: '450px', overflow: 'scroll'
919
+ alignContent: 'flex-start', padding: '2px 0', maxHeight: '450px', overflow: 'scroll',
707
920
  }});
708
921
 
709
922
  const redrawPreview = () => {
@@ -848,69 +1061,169 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
848
1061
  minHeight: '150px', padding: '4px 0',
849
1062
  } as const;
850
1063
 
851
- // ── Layout: horizontal split — cores + r-groups (left 60%) | preview (right 40%) ──
1064
+ // ── Section headers (shared between layouts) ──────────────────────────────
852
1065
  const coresHeader = sectionHeader(
853
1066
  'Cores', addCoreFromSketcher, addCoresFromImport,
854
1067
  () => { state.cores.splice(0, state.cores.length); refresh(); },
855
1068
  );
856
1069
  coresClearBtn = coresHeader.clearBtn;
857
- const coresSection = ui.divV([
858
- coresHeader.root,
859
- ui.div([coresVvHost, coresEmpty], {style: {padding: '0'}}),
860
- ], {style: sectionStyle});
861
1070
 
862
1071
  const rGroupsHeader = sectionHeader(
863
1072
  'R-Groups', addRGroupFromSketcher, addRGroupsFromImport,
864
1073
  () => { state.rGroupsByNum.clear(); refresh(); },
865
1074
  );
866
1075
  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
1076
 
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
- }});
1077
+ let body: HTMLElement;
1078
+ let appActionHost: HTMLElement | undefined;
1079
+
1080
+ if (layout === 'app') {
1081
+ // ── App: two flex columns. Left = cores + preview. Right = r-groups (grows) + controls (compact). ──
1082
+ const cellBaseStyle = {
1083
+ display: 'flex', flexDirection: 'column',
1084
+ minHeight: '0', minWidth: '0',
1085
+ padding: '8px', boxSizing: 'border-box',
1086
+ border: '1px solid var(--grey-2)', borderRadius: '4px',
1087
+ background: 'var(--white)', overflow: 'hidden',
1088
+ } as const;
1089
+ const growCellStyle = {...cellBaseStyle, flex: '1 1 0'} as const;
1090
+
1091
+ countText.style.fontSize = '11px';
1092
+ countText.style.color = 'var(--grey-5)';
1093
+
1094
+ const coresCell = ui.divV([
1095
+ coresHeader.root,
1096
+ coresVvHost,
1097
+ coresEmpty,
1098
+ ], {style: growCellStyle});
1099
+
1100
+ const rGroupsCell = ui.divV([
1101
+ rGroupsHeader.root,
1102
+ rGroupsHost,
1103
+ ], {style: growCellStyle});
1104
+
1105
+ const previewCell = ui.divV([
1106
+ sectionHeader('Preview').root,
1107
+ previewHost,
1108
+ ], {style: growCellStyle});
883
1109
 
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
- }});
1110
+ // Run header carries the live status — count + issues — inline on the right.
1111
+ const runLabel = ui.divText('Run', {style: {
1112
+ fontWeight: '600', fontSize: '12px', color: 'var(--grey-6)', alignSelf: 'center', minWidth: '40px',
1113
+ }});
1114
+ const runHeaderStats = ui.divH([countText, errorBadge.root], {style: {
1115
+ alignItems: 'center', gap: '8px', marginLeft: 'auto',
1116
+ }});
1117
+ const runHeader = ui.divH([runLabel, runHeaderStats], {style: {
1118
+ alignItems: 'center', gap: '8px', margin: '0 0 6px',
1119
+ flex: '0 0 auto', width: '100%',
1120
+ }});
889
1121
 
890
- const rightColumn = ui.divV([previewSection], {style: {
891
- flex: '0 0 40%', width: '40%',
892
- display: 'flex', flexDirection: 'column',
893
- paddingLeft: '8px',
894
- }});
1122
+ // History icon — sits next to Enumerate. Reads localStorage on click and pops a menu.
1123
+ const historyBtn = ui.iconFA('history', () => {
1124
+ showHistoryMenu((entry) => {
1125
+ applyHistoryEntry(entry, state, rdkit);
1126
+ refresh();
1127
+ });
1128
+ }, 'Show recent enumerations');
1129
+ historyBtn.style.fontSize = '16px';
1130
+ historyBtn.style.padding = '4px 6px';
1131
+ historyBtn.style.cursor = 'pointer';
1132
+ historyBtn.style.color = 'var(--blue-3)';
1133
+
1134
+ const clearAllBtn = ui.button('Clear all', () => {
1135
+ state.cores.splice(0, state.cores.length);
1136
+ state.rGroupsByNum.clear();
1137
+ refresh();
1138
+ });
1139
+ ui.tooltip.bind(clearAllBtn, 'Remove all cores and R-groups');
895
1140
 
896
- const split = ui.divH([leftColumn, rightColumn], {style: {
897
- width: '100%', alignItems: 'stretch', gap: '0',
898
- }});
1141
+ appActionHost = ui.div([], {style: {flex: '0 0 auto'}});
1142
+ const actionRow = ui.divH([historyBtn, appActionHost], {style: {
1143
+ alignItems: 'center', gap: '8px', marginTop: '8px', flex: '0 0 auto',
1144
+ }});
899
1145
 
900
- const statusLine = ui.divH([
901
- modeInput.root,
902
- countText,
903
- errorBadge.root,
904
- ], {style: {alignItems: 'center', gap: '16px', padding: '4px 0'}});
1146
+ const controlsCell = ui.divV([
1147
+ runHeader,
1148
+ modeInput.root,
1149
+ appendToTableInput.root,
1150
+ clearAllBtn,
1151
+ actionRow,
1152
+ ], {style: {
1153
+ ...cellBaseStyle, flex: '0 0 auto', overflow: 'visible',
1154
+ }});
905
1155
 
906
- const footer = ui.div([appendToTableInput.root], {style: {padding: '2px 0'}});
1156
+ const leftColumn = ui.divV([coresCell, previewCell], {style: {
1157
+ flex: '1 1 50%', minWidth: '0',
1158
+ display: 'flex', flexDirection: 'column', gap: '8px',
1159
+ }});
907
1160
 
908
- const body = ui.divV([
909
- split, statusLine, footer,
910
- ], {style: {
911
- width: '100%', padding: '4px',
912
- display: 'flex', flexDirection: 'column', gap: '4px',
913
- }});
1161
+ const rightColumn = ui.divV([rGroupsCell, controlsCell], {style: {
1162
+ flex: '1 1 50%', minWidth: '0',
1163
+ display: 'flex', flexDirection: 'column', gap: '8px',
1164
+ }});
1165
+
1166
+ body = ui.divH([leftColumn, rightColumn], {style: {
1167
+ width: '100%', height: '100%',
1168
+ display: 'flex', flexDirection: 'row',
1169
+ gap: '8px',
1170
+ padding: '4px', boxSizing: 'border-box',
1171
+ }});
1172
+ } else {
1173
+ // ── Dialog: horizontal split — cores + r-groups (left 60%) | preview (right 40%) ──
1174
+ const coresSection = ui.divV([
1175
+ coresHeader.root,
1176
+ ui.div([coresVvHost, coresEmpty], {style: {padding: '0'}}),
1177
+ ], {style: sectionStyle});
1178
+
1179
+ const rGroupsSection = ui.divV([
1180
+ rGroupsHeader.root,
1181
+ rGroupsHost,
1182
+ ], {style: {
1183
+ display: 'flex', flexDirection: 'column',
1184
+ maxHeight: '300px', padding: '4px 0',
1185
+ }});
1186
+
1187
+ const previewSection = ui.divV([
1188
+ sectionHeader(`Preview`).root,
1189
+ previewHost,
1190
+ ], {style: {
1191
+ ...sectionStyle,
1192
+ width: '100%', height: '100%',
1193
+ overflowY: 'auto', overflowX: 'hidden',
1194
+ }});
1195
+
1196
+ const leftColumn = ui.divV([coresSection, rGroupsSection], {style: {
1197
+ flex: '0 0 60%', width: '60%',
1198
+ display: 'flex', flexDirection: 'column', gap: '4px',
1199
+ paddingRight: '8px', borderRight: '1px solid var(--grey-2)',
1200
+ }});
1201
+
1202
+ const rightColumn = ui.divV([previewSection], {style: {
1203
+ flex: '0 0 40%', width: '40%',
1204
+ display: 'flex', flexDirection: 'column',
1205
+ paddingLeft: '8px',
1206
+ }});
1207
+
1208
+ const split = ui.divH([leftColumn, rightColumn], {style: {
1209
+ width: '100%', alignItems: 'stretch', gap: '0',
1210
+ }});
1211
+
1212
+ const statusLine = ui.divH([
1213
+ modeInput.root,
1214
+ countText,
1215
+ errorBadge.root,
1216
+ ], {style: {alignItems: 'center', gap: '16px', padding: '4px 0'}});
1217
+
1218
+ const footer = ui.div([appendToTableInput.root], {style: {padding: '2px 0'}});
1219
+
1220
+ body = ui.divV([
1221
+ split, statusLine, footer,
1222
+ ], {style: {
1223
+ width: '100%', padding: '4px',
1224
+ display: 'flex', flexDirection: 'column', gap: '4px',
1225
+ }});
1226
+ }
914
1227
 
915
1228
  refresh();
916
1229
  return {
@@ -918,6 +1231,8 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
918
1231
  state,
919
1232
  execute: () => executeEnumeration(state, rdkit),
920
1233
  bindActionButton: (btn) => { okButton = btn; refresh(); },
1234
+ refresh,
1235
+ appActionHost,
921
1236
  };
922
1237
  }
923
1238
 
@@ -932,13 +1247,17 @@ async function executeEnumeration(state: ChemEnumDialogState, _rdkit: RDModule):
932
1247
  if (!results) { grok.shell.warning('Enumeration failed — check validation messages in the dialog.'); return; }
933
1248
  if (results.length === 0) { grok.shell.warning('No molecules produced.'); return; }
934
1249
 
1250
+ // Record this configuration in history before any post-processing — if the user closes
1251
+ // mid-canonicalization, they can still recover the inputs that produced something.
1252
+ recordHistory(state);
1253
+
935
1254
  const rNumbersUsed = new Set<number>();
936
1255
  for (const r of results) for (const n of r.rGroupSmilesByNum.keys()) rNumbersUsed.add(n);
937
1256
  const sortedRs = [...rNumbersUsed].sort((a, b) => a - b);
938
1257
 
939
1258
  const smilesCol = DG.Column.fromStrings('Enumerated', results.map((r) => r.smiles));
940
1259
  smilesCol.semType = DG.SEMTYPE.MOLECULE;
941
- const coreCol = DG.Column.fromStrings('Core', results.map((r) => r.coreSmiles));
1260
+ const coreCol = DG.Column.fromStrings('Core', results.map((r) => normalizeRLabels(r.coreSmiles ?? '')));
942
1261
  coreCol.semType = DG.SEMTYPE.MOLECULE;
943
1262
  const rCols = sortedRs.map((n) =>
944
1263
  DG.Column.fromStrings(`R${n}`, results.map((r) => r.rGroupSmilesByNum.get(n) ?? '')));