flexdesk 0.16.0 → 0.17.0

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 (251) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2261 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/css/base.css +1834 -75
  6. package/css/flexdesk.css +3373 -238
  7. package/css/overrides.css +16 -0
  8. package/css/reset.css +0 -0
  9. package/css/tokens.css +0 -0
  10. package/dist/canvas.js +6 -239
  11. package/dist/canvas.js.map +3 -3
  12. package/dist/charts.js +709 -18
  13. package/dist/charts.js.map +4 -4
  14. package/dist/chunk-3PHCPZHT.js +0 -0
  15. package/dist/chunk-3PHCPZHT.js.map +0 -0
  16. package/dist/chunk-4A7SJC3T.js +503 -0
  17. package/dist/chunk-4A7SJC3T.js.map +7 -0
  18. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  19. package/dist/chunk-CVMCZZX2.js.map +7 -0
  20. package/dist/chunk-D3COLZZ2.js +1311 -0
  21. package/dist/chunk-D3COLZZ2.js.map +7 -0
  22. package/dist/chunk-FOOS3T5L.js +0 -0
  23. package/dist/chunk-FOOS3T5L.js.map +0 -0
  24. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  25. package/dist/chunk-GOQLR5WR.js.map +7 -0
  26. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  27. package/dist/chunk-HNU6MM2B.js.map +7 -0
  28. package/dist/chunk-HYJYYEPK.js +375 -0
  29. package/dist/chunk-HYJYYEPK.js.map +7 -0
  30. package/dist/chunk-JKZLNKG6.js +2041 -0
  31. package/dist/chunk-JKZLNKG6.js.map +7 -0
  32. package/dist/chunk-JYWURG5T.js +0 -0
  33. package/dist/chunk-JYWURG5T.js.map +0 -0
  34. package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
  35. package/dist/chunk-MCIXFNVF.js.map +7 -0
  36. package/dist/chunk-N7HC3ADV.js +30 -0
  37. package/dist/chunk-N7HC3ADV.js.map +7 -0
  38. package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
  39. package/dist/chunk-Q36EPT2R.js.map +7 -0
  40. package/dist/chunk-QWQJUDPD.js +610 -0
  41. package/dist/chunk-QWQJUDPD.js.map +7 -0
  42. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  43. package/dist/chunk-UURZ37SD.js.map +7 -0
  44. package/dist/chunk-UWO43BZU.js +111 -0
  45. package/dist/chunk-UWO43BZU.js.map +7 -0
  46. package/dist/chunk-WVFGV5FT.js +0 -0
  47. package/dist/chunk-WVFGV5FT.js.map +0 -0
  48. package/dist/core.js +170 -118
  49. package/dist/core.js.map +2 -2
  50. package/dist/editor.js +156 -70
  51. package/dist/editor.js.map +2 -2
  52. package/dist/flexdesk.css +3373 -238
  53. package/dist/flow.js +2789 -306
  54. package/dist/flow.js.map +4 -4
  55. package/dist/host.js +109 -3
  56. package/dist/host.js.map +3 -3
  57. package/dist/reset.css +0 -0
  58. package/dist/tile_registry-6WZPTUZV.js +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  60. package/dist/tiles.js +1131 -253
  61. package/dist/tiles.js.map +4 -4
  62. package/dist/tokens.css +0 -0
  63. package/dist/widgets.js +3254 -2112
  64. package/dist/widgets.js.map +4 -4
  65. package/dist/wm.js +1227 -345
  66. package/dist/wm.js.map +4 -4
  67. package/package.json +3 -1
  68. package/src/canvas/canvas_adapter.js +5 -0
  69. package/src/canvas/edge_router.js +0 -0
  70. package/src/canvas/graph/connector_router.js +0 -0
  71. package/src/canvas/graph/history_service.js +0 -0
  72. package/src/canvas/graph/node_platform.js +0 -0
  73. package/src/canvas/index.js +1 -0
  74. package/src/canvas/layered_layout.js +244 -0
  75. package/src/charts/chart_types.js +0 -0
  76. package/src/charts/data_series_plot_window.js +0 -0
  77. package/src/charts/downsample.js +2 -2
  78. package/src/charts/plot_config.js +53 -29
  79. package/src/charts/plot_config_editor.js +627 -0
  80. package/src/charts/plot_grid.js +449 -0
  81. package/src/charts/plot_popout_window.js +103 -317
  82. package/src/charts/plotly_wrapper.js +22 -2
  83. package/src/charts/series_transforms.js +206 -0
  84. package/src/core/event_bus.js +0 -0
  85. package/src/core/logging.js +1 -1
  86. package/src/core/settings.js +105 -19
  87. package/src/core/state_guard.js +93 -51
  88. package/src/core/state_guard_config.js +85 -74
  89. package/src/core/state_machine.js +0 -0
  90. package/src/editor/editor_pane.js +3 -3
  91. package/src/editor/monaco_editor_factory.js +124 -60
  92. package/src/editor/monaco_loader.js +103 -28
  93. package/src/editor/notebook_search.js +0 -0
  94. package/src/editor/notebook_tab_bar.js +38 -52
  95. package/src/editor/notebook_undo_manager.js +0 -0
  96. package/src/editor/split_pane_container.js +6 -3
  97. package/src/flow/canvas_editor/connections.js +90 -0
  98. package/src/flow/canvas_editor/editor.js +2351 -153
  99. package/src/flow/canvas_editor/index.js +2 -1
  100. package/src/flow/canvas_editor/layout.js +119 -12
  101. package/src/flow/canvas_editor/strings.js +80 -0
  102. package/src/flow/kit/always_before.js +0 -0
  103. package/src/flow/kit/catalogue.js +97 -3
  104. package/src/flow/kit/chip_input.js +0 -0
  105. package/src/flow/kit/dom.js +0 -0
  106. package/src/flow/kit/findings.js +0 -0
  107. package/src/flow/kit/history.js +0 -0
  108. package/src/flow/kit/index.js +3 -3
  109. package/src/flow/kit/keys.js +7 -1
  110. package/src/flow/kit/logic_graph.js +97 -15
  111. package/src/flow/kit/references.js +0 -0
  112. package/src/flow/kit/settings_panel.js +0 -0
  113. package/src/flow/kit/settings_schema.js +2 -1
  114. package/src/flow/kit/step_picker.js +0 -0
  115. package/src/flow/kit/strings.js +3 -1
  116. package/src/flow/kit/value_picker.js +0 -0
  117. package/src/flow/kit/widgets.js +86 -109
  118. package/src/flow/lanes/editor.js +136 -14
  119. package/src/flow/lanes/index.js +3 -1
  120. package/src/flow/lanes/layout.js +0 -0
  121. package/src/flow/lanes/pipeline.js +44 -2
  122. package/src/flow/lanes/preview.js +0 -0
  123. package/src/flow/lanes/strings.js +1 -0
  124. package/src/flow/outline/editor.js +0 -0
  125. package/src/flow/outline/index.js +0 -0
  126. package/src/flow/outline/mapping.js +0 -0
  127. package/src/flow/outline/menu.js +0 -0
  128. package/src/flow/outline/operations.js +0 -0
  129. package/src/flow/outline/recognise.js +0 -0
  130. package/src/flow/outline/strings.js +0 -0
  131. package/src/help/help_modal.js +168 -42
  132. package/src/help/help_registry.js +12 -1
  133. package/src/host/external_links.js +0 -0
  134. package/src/host/file_picker.js +117 -0
  135. package/src/host/host.js +10 -2
  136. package/src/host/photino_host.js +9 -0
  137. package/src/host/photino_host.md +21 -3
  138. package/src/host/pywebview_host.js +10 -0
  139. package/src/tiles/chart_tiles.js +53 -245
  140. package/src/tiles/config_schema.js +8 -0
  141. package/src/tiles/figure_tile.js +322 -0
  142. package/src/tiles/layout_persistence.js +3 -3
  143. package/src/tiles/list_tiles.js +422 -0
  144. package/src/tiles/scope_picker.js +96 -0
  145. package/src/tiles/tile_base.js +12 -1
  146. package/src/tiles/tile_config_form.js +442 -0
  147. package/src/tiles/tile_grid.js +38 -1
  148. package/src/tiles/tile_registry.js +0 -0
  149. package/src/tiling/bottom_panel_tabs.js +67 -99
  150. package/src/tiling/command_palette.js +64 -19
  151. package/src/tiling/content_registry.js +4 -1
  152. package/src/tiling/desktops.js +11 -1
  153. package/src/tiling/entity_sources.js +0 -0
  154. package/src/tiling/keymap.js +403 -128
  155. package/src/tiling/kind_taxonomy.js +78 -0
  156. package/src/tiling/landing_table.js +0 -0
  157. package/src/tiling/loading_overlay.js +0 -0
  158. package/src/tiling/nav_panel.js +59 -10
  159. package/src/tiling/page_factory.js +5 -5
  160. package/src/tiling/panel_availability.js +49 -0
  161. package/src/tiling/panel_keys.js +0 -0
  162. package/src/tiling/shell.js +114 -12
  163. package/src/tiling/tab_strip.js +0 -0
  164. package/src/tiling/tile_breadcrumb.js +0 -0
  165. package/src/tiling/tile_renderer.js +105 -9
  166. package/src/tiling/tile_tab_menu.js +0 -0
  167. package/src/tiling/tile_tree.js +0 -0
  168. package/src/tiling/wm.js +562 -30
  169. package/src/tiling/zoom.js +0 -0
  170. package/src/ui/base/component_base.js +0 -0
  171. package/src/ui/base/controller_base.js +0 -0
  172. package/src/ui/base/page_base.js +0 -0
  173. package/src/ui/components/about_dialog.js +0 -0
  174. package/src/ui/components/action_dropdown.js +30 -2
  175. package/src/ui/components/alert_log.js +0 -0
  176. package/src/ui/components/alerts_pane.js +0 -0
  177. package/src/ui/components/attribute_list_editor.js +0 -0
  178. package/src/ui/components/autocomplete_field.js +0 -0
  179. package/src/ui/components/cell_editor_host.js +211 -0
  180. package/src/ui/components/cell_grid.js +648 -0
  181. package/src/ui/components/cell_selection.js +220 -0
  182. package/src/ui/components/choice_controls.js +351 -0
  183. package/src/ui/components/color_field.js +211 -0
  184. package/src/ui/components/color_list_field.js +210 -0
  185. package/src/ui/components/column_fit.js +0 -0
  186. package/src/ui/components/column_groups.js +314 -0
  187. package/src/ui/components/column_order.js +142 -0
  188. package/src/ui/components/computing_status_window.js +98 -25
  189. package/src/ui/components/confirm_dialog.js +81 -38
  190. package/src/ui/components/context_menu.js +2 -2
  191. package/src/ui/components/conversation_view.js +440 -0
  192. package/src/ui/components/crosstab.js +289 -0
  193. package/src/ui/components/data_table.js +601 -62
  194. package/src/ui/components/detail_header.js +22 -11
  195. package/src/ui/components/document_outline.js +397 -0
  196. package/src/ui/components/drag_reorder.js +0 -0
  197. package/src/ui/components/fact_table.js +0 -0
  198. package/src/ui/components/fill_handle.js +322 -0
  199. package/src/ui/components/gallery_picker.js +19 -8
  200. package/src/ui/components/header_actions.js +55 -0
  201. package/src/ui/components/inline_renamer.js +0 -0
  202. package/src/ui/components/job_progress.js +51 -0
  203. package/src/ui/components/managed_window.js +27 -4
  204. package/src/ui/components/menu_bar.js +745 -0
  205. package/src/ui/components/modal.js +449 -75
  206. package/src/ui/components/notification_history.js +3 -3
  207. package/src/ui/components/progress.js +483 -0
  208. package/src/ui/components/record_list.js +0 -0
  209. package/src/ui/components/row_activation.js +0 -0
  210. package/src/ui/components/section_stack.js +298 -0
  211. package/src/ui/components/series.js +173 -0
  212. package/src/ui/components/settings_view.js +634 -0
  213. package/src/ui/components/slide_out_panel.js +43 -6
  214. package/src/ui/components/slider_field.js +275 -18
  215. package/src/ui/components/sortable_list.js +26 -4
  216. package/src/ui/components/staged_edits.js +343 -0
  217. package/src/ui/components/staged_history.js +172 -0
  218. package/src/ui/components/table_filter_chips.js +0 -0
  219. package/src/ui/components/table_state_store.js +0 -0
  220. package/src/ui/components/table_structure.js +182 -0
  221. package/src/ui/components/table_values.js +0 -0
  222. package/src/ui/components/tabs.js +497 -0
  223. package/src/ui/components/tag_input.js +215 -0
  224. package/src/ui/components/toast.js +25 -8
  225. package/src/ui/components/toggle_field.js +175 -0
  226. package/src/ui/components/tree_view.js +55 -34
  227. package/src/ui/components/window_taskbar.js +0 -0
  228. package/src/ui/components/wizard.js +563 -0
  229. package/src/ui/controllers/window_chrome_controller.js +2 -2
  230. package/src/ui/notification_center.js +375 -119
  231. package/src/ui/utils/auto_scrollbars.js +0 -0
  232. package/src/ui/utils/markdown.js +709 -0
  233. package/src/ui/utils/native_context_menu.js +0 -0
  234. package/src/ui/utils/overlay_scrollbar.js +16 -12
  235. package/src/ui/utils/raf_resize_observer.js +0 -0
  236. package/src/ui/utils/scroll_lock.js +0 -0
  237. package/src/ui/utils/strip_scroll.js +55 -0
  238. package/src/ui/utils/tooltip_service.js +0 -8
  239. package/dist/chunk-6JSOVNID.js.map +0 -7
  240. package/dist/chunk-CWPMLX67.js +0 -475
  241. package/dist/chunk-CWPMLX67.js.map +0 -7
  242. package/dist/chunk-FBGP5KN5.js.map +0 -7
  243. package/dist/chunk-FL5KFNQH.js.map +0 -7
  244. package/dist/chunk-NLTOY57M.js.map +0 -7
  245. package/dist/chunk-NNFTETK3.js +0 -633
  246. package/dist/chunk-NNFTETK3.js.map +0 -7
  247. package/dist/chunk-O5OHMWBB.js +0 -284
  248. package/dist/chunk-O5OHMWBB.js.map +0 -7
  249. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  250. package/dist/chunk-SN4LSOAR.js.map +0 -7
  251. package/src/ui/controllers/panel_state_machine.js +0 -634
@@ -11,12 +11,14 @@
11
11
  * action, square frame, Tab focus trap).
12
12
  *
13
13
  * The one custom case is `showRunProgressDialog`, which exposes an
14
- * imperative `update / complete / cancelled / close` API and so
14
+ * imperative `update / setProgress / setStep / complete / cancelled /
15
+ * close` API and a `result` promise, and so
15
16
  * stays as a direct `ManagedWindow` build — but it still benefits
16
17
  * from the focus trap + chrome by virtue of `modal: true`.
17
18
  */
18
19
 
19
20
  import { ManagedWindow } from './managed_window.js';
21
+ import { mountJobProgress } from './job_progress.js';
20
22
  import {
21
23
  openConfirm, openForm, openModal,
22
24
  } from './modal.js';
@@ -65,10 +67,12 @@ export async function showDeleteConfirmDialog(options = {}) {
65
67
  itemType = 'item',
66
68
  additionalMessage = '',
67
69
  } = options;
70
+ // `openConfirm` renders the message as text, so a user-named item
71
+ // (`<b>x</b>`, an unclosed `<`) is shown exactly as it was typed.
68
72
  let message = `Delete ${itemType}`;
69
73
  if (itemName) message += ` "${itemName}"`;
70
74
  message += '?';
71
- if (additionalMessage) message += `<br><br>${escapeHtml(additionalMessage)}`;
75
+ if (additionalMessage) message += `\n\n${additionalMessage}`;
72
76
  return openConfirm({
73
77
  title: `Delete ${capitalize(itemType)}`,
74
78
  message,
@@ -189,41 +193,73 @@ function capitalize(str) {
189
193
  }
190
194
 
191
195
 
192
- // ─── showRunProgressDialog (unchanged custom build) ─────────────────
196
+ // ─── showRunProgressDialog (custom build) ───────────────────────────
193
197
  //
194
- // This one exposes an imperative API (`update`, `complete`, `cancelled`,
195
- // `close`) that the openModal/openForm/openConfirm helpers don't model
196
- // — they're one-shot promise-returning. Keep the direct ManagedWindow
197
- // build, but it still gets the TUI chrome + focus trap for free via
198
- // `modal: true`.
198
+ // This one exposes an imperative API (`update`, `setProgress`, `setStep`,
199
+ // `complete`, `cancelled`, `close`) that the openModal/openForm/openConfirm
200
+ // helpers don't model — they're one-shot promise-returning. Keep the direct
201
+ // ManagedWindow build, but it still gets the TUI chrome + focus trap for free
202
+ // via `modal: true`.
199
203
 
200
204
  /**
201
- * Show a run progress dialog with spinner, live message updates, and
202
- * final status. Returns a control object — keep this build instead of
203
- * delegating so callers retain the imperative update / complete API.
204
- * @returns {{ update(msg: string): void, complete(result: {ok: boolean, message: string}): void, cancelled(msg?: string): void, close(): void }}
205
+ * @typedef {Object} RunProgressResult
206
+ * @property {'done'|'error'|'cancelled'} status
207
+ * @property {string} message
208
+ */
209
+
210
+ /**
211
+ * Show a run progress dialog with a spinner (or a bar), live message
212
+ * updates, optional phases, and a final status.
213
+ *
214
+ * `progress` (`true` or ProgressBar options) shows a determinate bar in place
215
+ * of the spinner; `steps` (StepProgress steps) shows the phases above it.
216
+ * Both are fixed when the dialog opens: `setProgress` throws if it was opened
217
+ * without `progress`, and `setStep` if it was opened without `steps`.
218
+ * `result` settles once, with the final state: `complete({ok})` gives `done`
219
+ * or `error`, `cancelled()` gives `cancelled`, and so does closing the dialog
220
+ * while it runs (by its ×, Escape or `close()`). Closing never calls
221
+ * `onCancel`: only the Stop button does.
222
+ *
223
+ * @param {Object} [options]
224
+ * @param {string} [options.title='Running Pipeline']
225
+ * @param {string} [options.message='Starting…']
226
+ * @param {Function} [options.onCancel] - Shows a Stop button.
227
+ * @param {boolean|Object} [options.progress] - A ProgressBar, `true` or its options.
228
+ * @param {Array} [options.steps] - StepProgress steps.
229
+ * @returns {{ update(msg: string): void, setProgress(next: number|Object): void,
230
+ * setStep(which: string|number, opts?: {pass?: number, passes?: number}): void,
231
+ * complete(result: {ok: boolean, message: string}): void, cancelled(msg?: string): void,
232
+ * close(): void, result: Promise<RunProgressResult> }}
205
233
  */
206
234
  export function showRunProgressDialog(options = {}) {
207
235
  const {
208
236
  title = 'Running Pipeline',
209
237
  message = 'Starting…',
210
238
  onCancel,
239
+ progress = null,
240
+ steps = null,
211
241
  } = options;
212
242
 
213
243
  const dialogId = `run-progress-${++dialogIdCounter}`;
214
244
  let finished = false;
245
+ let settle;
246
+ const result = new Promise((resolve) => { settle = resolve; });
215
247
 
216
248
  const contentEl = document.createElement('div');
217
249
  contentEl.className = 'twm-confirm-dialog__content run-progress';
218
250
 
219
251
  const bodyEl = document.createElement('div');
220
252
  bodyEl.className = 'twm-run-progress__body';
221
- bodyEl.innerHTML = `
222
- <div class="twm-run-progress__spinner"></div>
223
- <div class="twm-run-progress__message">${escapeHtml(message)}</div>
224
- `;
253
+ const spinnerEl = document.createElement('div');
254
+ spinnerEl.className = 'twm-run-progress__spinner';
255
+ const messageEl = document.createElement('div');
256
+ messageEl.className = 'twm-run-progress__message';
257
+ messageEl.textContent = message;
258
+ bodyEl.append(spinnerEl, messageEl);
225
259
  contentEl.appendChild(bodyEl);
226
260
 
261
+ const job = mountJobProgress(contentEl, { progress, steps, spinner: spinnerEl });
262
+
227
263
  let stopBtn = null;
228
264
  if (typeof onCancel === 'function') {
229
265
  const stopActionsEl = document.createElement('div');
@@ -253,6 +289,17 @@ export function showRunProgressDialog(options = {}) {
253
289
  actionsEl.appendChild(closeBtn);
254
290
  contentEl.appendChild(actionsEl);
255
291
 
292
+ const finish = (status, text, cls) => {
293
+ finished = true;
294
+ spinnerEl.style.display = 'none';
295
+ if (stopBtn) stopBtn.parentElement.style.display = 'none';
296
+ messageEl.textContent = text;
297
+ messageEl.className = `twm-run-progress__message ${cls}`;
298
+ job.finish(status);
299
+ actionsEl.style.display = '';
300
+ settle({ status, message: text });
301
+ };
302
+
256
303
  const dialogWindow = new ManagedWindow({
257
304
  id: dialogId,
258
305
  title,
@@ -261,44 +308,40 @@ export function showRunProgressDialog(options = {}) {
261
308
  minWidth: 320,
262
309
  minHeight: 120,
263
310
  defaultWidth: 420,
264
- defaultHeight: 200,
311
+ defaultHeight: 200 + (job.bar ? 40 : 0) + (job.steps ? 40 : 0),
265
312
  canMinimize: false,
266
313
  canMaximize: false,
267
314
  canResize: false,
268
315
  canDrag: false,
269
316
  modal: true,
317
+ onClose: () => {
318
+ if (finished) return;
319
+ finish('cancelled', 'Cancelled.', 'is-cancelled');
320
+ },
270
321
  });
271
322
  dialogWindow.show();
272
323
 
273
- const messageEl = bodyEl.querySelector('.twm-run-progress__message');
274
- const spinnerEl = bodyEl.querySelector('.twm-run-progress__spinner');
275
-
276
324
  return {
325
+ result,
277
326
  update(msg) {
278
- if (finished || !messageEl) return;
327
+ if (finished) return;
279
328
  messageEl.textContent = msg;
280
329
  },
281
- complete(result) {
330
+ setProgress(next) {
331
+ if (finished) return;
332
+ job.setProgress(next);
333
+ },
334
+ setStep(which, opts) {
335
+ if (finished) return;
336
+ job.setStep(which, opts);
337
+ },
338
+ complete(res) {
282
339
  if (finished) return;
283
- finished = true;
284
- if (spinnerEl) spinnerEl.style.display = 'none';
285
- if (stopBtn) stopBtn.parentElement.style.display = 'none';
286
- if (messageEl) {
287
- messageEl.textContent = result?.message || '';
288
- messageEl.className = `twm-run-progress__message ${result?.ok ? 'is-ok' : 'is-err'}`;
289
- }
290
- actionsEl.style.display = '';
340
+ finish(res?.ok ? 'done' : 'error', res?.message || '', res?.ok ? 'is-ok' : 'is-err');
291
341
  },
292
342
  cancelled(msg) {
293
343
  if (finished) return;
294
- finished = true;
295
- if (spinnerEl) spinnerEl.style.display = 'none';
296
- if (stopBtn) stopBtn.parentElement.style.display = 'none';
297
- if (messageEl) {
298
- messageEl.textContent = msg || 'Cancelled.';
299
- messageEl.className = 'twm-run-progress__message is-cancelled';
300
- }
301
- actionsEl.style.display = '';
344
+ finish('cancelled', msg || 'Cancelled.', 'is-cancelled');
302
345
  },
303
346
  close() {
304
347
  try { dialogWindow.close(); } catch { /* already closed */ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * context_menu.js — small popup menu that matches Ecosim's `.context-menu`
3
- * idiom (see main_new.css line 9713 and ui/js/ui/components/data_table.js).
2
+ * context_menu.js — small popup menu in the `.context-menu` idiom the
3
+ * DataTable's own right-click menu uses (./data_table.js).
4
4
  *
5
5
  * Usage:
6
6
  * showContextMenu(x, y, [
@@ -0,0 +1,440 @@
1
+ /**
2
+ * ══ THE CONVERSATION VIEW ═════════════════════════════════════════════════
3
+ *
4
+ * A transcript of role-tagged messages above a composer (FlexDesk STORY-0057).
5
+ * It knows nothing about what a message says or where it came from: no
6
+ * markdown, no model, no provider. The caller draws a message's body (or
7
+ * leaves it as plain text) and is told when the user submits or cancels.
8
+ *
9
+ * const view = mountConversationView(el, {
10
+ * render: (message, bodyEl, { streaming }) => { bodyEl.innerHTML = toHtml(message.text); },
11
+ * onSubmit: (text) => send(text),
12
+ * onCancel: () => stop(),
13
+ * });
14
+ * view.append({ role: 'user', text: 'Hello' });
15
+ * view.beginStream({ role: 'assistant' });
16
+ * view.appendChunk('Hi'); // re-renders ONLY the streaming message
17
+ * view.endStream();
18
+ *
19
+ * MESSAGES are `{ id?, role, text, meta? }`. `id` is assigned when absent;
20
+ * `role` becomes a modifier class (`twm-chat__msg--<role>`); `meta` is the
21
+ * caller's and is handed back untouched. `append`, `replaceAll` and `clear`
22
+ * change the list; `messages()` reads it. An id already in the transcript (or
23
+ * repeated within one `replaceAll`) is refused with a throw, and a refused
24
+ * call changes nothing. Each message has an EXTRA SLOT
25
+ * (`slot(id)`), an element below the body the caller fills with whatever
26
+ * belongs to that message — tool output, buttons, a diff — and which a
27
+ * re-render of the body never touches.
28
+ *
29
+ * THE RENDER CALLBACK draws one body: `render(message, bodyEl, { streaming })`.
30
+ * The body is emptied before every call. One that throws leaves the message
31
+ * as its plain text (`twm-chat__body--plain`) and the view carries on; the
32
+ * error goes to `onRenderError(error, message)` when given.
33
+ *
34
+ * STREAMING: `beginStream(message?)` appends a message marked streaming,
35
+ * `appendChunk(text)` adds to its text and re-renders that message's body
36
+ * alone, `endStream()` renders it a last time, unmarked. Nothing is deferred to
37
+ * a frame: every call has drawn by the time it returns.
38
+ *
39
+ * PINNED TO THE BOTTOM: the view starts out following new content. Any
40
+ * scroll UP — even a few px from the end, and whichever frame of a smooth
41
+ * wheel or key scroll reports it — stops the following and shows *Jump to
42
+ * latest*, and no later chunk moves the reader back. Following resumes only
43
+ * on a scroll down (or a move that is not up) that ends within
44
+ * `pinThreshold` px of the end, a click on *Jump to latest*,
45
+ * `scrollToBottom()`, `replaceAll()` / `clear()`, or a `setScrollTop(px)`
46
+ * that lands within the threshold. Content growing below a pinned view
47
+ * never unpins it, nor does content shrinking under it (the browser clamps
48
+ * `scrollTop` to the new end, which is not a move up). A wheel over
49
+ * a nested scroller (a `<pre>` in a body, say) scrolls that, not the view;
50
+ * nor does a wheel a handler inside the transcript cancelled, a Ctrl+wheel
51
+ * (zoom, or a trackpad pinch), or a wheel up over a transcript already at its
52
+ * top. `scrollTop` is
53
+ * readable and settable (`getScrollTop` / `setScrollTop`) so an embedder can
54
+ * keep it across a remount.
55
+ *
56
+ * THE COMPOSER is a textarea that grows with its text up to
57
+ * `composerMaxHeight`. Enter submits (the trimmed-empty text is ignored) and
58
+ * clears it; Shift+Enter is left to the browser, which inserts the newline
59
+ * (native undo, `input` events). `setBusy(true)` turns Send into Cancel: Enter
60
+ * then submits nothing, and Esc — wherever focus is inside the view — or a
61
+ * click on Cancel calls `onCancel`, once per busy period, however often it is
62
+ * pressed. That Esc stops there: it never reaches a window or panel around
63
+ * the view that would close on Escape. An Esc a handler inside the view has
64
+ * already consumed (`preventDefault`, say an inline popup or editor in the
65
+ * transcript closing itself) is left alone: it neither cancels nor is stopped.
66
+ *
67
+ * `destroy()` removes the view. Every listener sits on an element the view
68
+ * created, so they go with it; none is on the host or the document.
69
+ */
70
+
71
+ /** How near the end of the transcript still counts as "at the bottom". */
72
+ export const CONVERSATION_PIN_WITHIN_PX = 32;
73
+
74
+ /**
75
+ * A drop in `scrollTop` that still leaves the view this close to its end is
76
+ * the browser clamping it after the content shrank (or sub-pixel rounding),
77
+ * not the reader scrolling up.
78
+ */
79
+ const AT_END_PX = 1;
80
+
81
+ /** The tallest the composer grows before it scrolls instead. */
82
+ export const CONVERSATION_COMPOSER_MAX_PX = 200;
83
+
84
+ /**
85
+ * @param {HTMLElement} host
86
+ * @param {object} [opts]
87
+ * @param {Function} [opts.render] `(message, bodyEl, { streaming })` — draws a body; plain text without it
88
+ * @param {Function} [opts.onSubmit] `(text)` — Enter or Send
89
+ * @param {Function} [opts.onCancel] `()` — Esc or Cancel while busy
90
+ * @param {Function} [opts.onRenderError] `(error, message)` — a render that threw
91
+ * @param {number} [opts.pinThreshold] px from the end that still follows new content
92
+ * @param {number} [opts.composerMaxHeight] px the composer grows to
93
+ * @param {string} [opts.placeholder] the composer's placeholder
94
+ * @param {object} [opts.labels] `{ send, cancel, jump, input }` — button and field texts
95
+ * @returns {object} the view (see the methods below)
96
+ */
97
+ export function mountConversationView(host, {
98
+ render = null, onSubmit = null, onCancel = null, onRenderError = null,
99
+ pinThreshold = CONVERSATION_PIN_WITHIN_PX, composerMaxHeight = CONVERSATION_COMPOSER_MAX_PX,
100
+ placeholder = '', labels = {},
101
+ } = {}) {
102
+ const text = {
103
+ send: 'Send', cancel: 'Cancel', jump: 'Jump to latest', input: 'Message', ...labels,
104
+ };
105
+ const doc = host.ownerDocument;
106
+ const el = (tag, cls, content) => {
107
+ const n = doc.createElement(tag);
108
+ if (cls) n.className = cls;
109
+ if (content !== undefined) n.textContent = content;
110
+ return n;
111
+ };
112
+ const icon = (name) => {
113
+ const i = el('span', 'material-symbols-outlined', name);
114
+ i.setAttribute('aria-hidden', 'true');
115
+ return i;
116
+ };
117
+
118
+ /** id → { message, row, body, extra } in transcript order. */
119
+ const entries = new Map();
120
+ let nextId = 1;
121
+ let streamId = null;
122
+ let pinned = true;
123
+ let lastScrollTop = 0;
124
+ let busy = false;
125
+ let cancelled = false;
126
+ let destroyed = false;
127
+
128
+ // ── the frame ────────────────────────────────────────────────────────
129
+ const root = el('div', 'twm-chat');
130
+ const viewport = el('div', 'twm-chat__viewport');
131
+ const log = el('div', 'twm-chat__log');
132
+ log.setAttribute('role', 'log');
133
+ log.setAttribute('aria-live', 'polite');
134
+ const jump = el('button', 'twm-chat__jump');
135
+ jump.type = 'button';
136
+ jump.append(icon('arrow_downward'), doc.createTextNode(` ${text.jump}`));
137
+ jump.hidden = true;
138
+ viewport.append(log, jump);
139
+
140
+ const composer = el('div', 'twm-chat__composer');
141
+ const input = el('textarea', 'twm-chat__input');
142
+ input.rows = 1;
143
+ input.placeholder = placeholder;
144
+ input.setAttribute('aria-label', text.input);
145
+ const send = el('button', 'twm-chat__send');
146
+ send.type = 'button';
147
+ composer.append(input, send);
148
+ root.append(viewport, composer);
149
+ host.append(root);
150
+
151
+ // ── scrolling ────────────────────────────────────────────────────────
152
+ const distanceFromEnd = () => log.scrollHeight - log.scrollTop - log.clientHeight;
153
+ const toBottom = () => {
154
+ log.scrollTop = log.scrollHeight;
155
+ lastScrollTop = log.scrollTop;
156
+ };
157
+ const setPinned = (value) => {
158
+ pinned = value;
159
+ jump.hidden = pinned;
160
+ };
161
+ /**
162
+ * Reads the position the reader left: any move up stops following, however
163
+ * near the end it stops (a smooth wheel or key scroll reports its first
164
+ * frame only a few px up). Returns whether the move may follow again: it
165
+ * was not up and ends within `pinThreshold` of the end. Shared by the
166
+ * `scroll` event and by settle(), because a scrollbar drag, a key or a
167
+ * touch moves `scrollTop` before its `scroll` event is dispatched, and a
168
+ * change arriving in between must not snap the reader back.
169
+ */
170
+ function noticeMove() {
171
+ const top = log.scrollTop;
172
+ const distance = distanceFromEnd();
173
+ const movedUp = top < lastScrollTop && distance > AT_END_PX;
174
+ lastScrollTop = top;
175
+ if (movedUp && pinned) setPinned(false);
176
+ return !movedUp && distance <= pinThreshold;
177
+ }
178
+ /** After every change to the transcript: follow it, or leave the reader where they are. */
179
+ const settle = () => {
180
+ noticeMove();
181
+ if (pinned) toBottom();
182
+ };
183
+
184
+ function onScroll() { if (noticeMove() && !pinned) setPinned(true); }
185
+ /**
186
+ * Whether a wheel up over `target` is kept by something nested inside the
187
+ * transcript — a scrollable block in a body or an extra slot that is not
188
+ * at its own top, or one at its top whose `overscroll-behavior` keeps the
189
+ * scroll from chaining out — rather than moving the transcript itself.
190
+ */
191
+ function innerTakesWheelUp(target) {
192
+ const view = doc.defaultView;
193
+ for (let n = target; n && n !== log; n = n.parentElement) {
194
+ if (n.nodeType !== 1 || n.scrollHeight <= n.clientHeight) continue;
195
+ const style = view?.getComputedStyle(n);
196
+ const overflow = style?.overflowY;
197
+ if (overflow !== 'auto' && overflow !== 'scroll' && overflow !== 'overlay') continue;
198
+ if (n.scrollTop > 0) return true;
199
+ const chain = style.getPropertyValue('overscroll-behavior-y');
200
+ if (chain === 'contain' || chain === 'none') return true;
201
+ }
202
+ return false;
203
+ }
204
+ /**
205
+ * A wheel up that will move the transcript stops following at once, before
206
+ * the scroll it causes is reported. One that a nested scroller keeps, or
207
+ * one a handler inside the transcript cancelled (a zoomable chart in a
208
+ * slot, say), leaves the transcript where it is, so it changes nothing.
209
+ * Nor does a Ctrl+wheel (page zoom; a trackpad pinch arrives as one), or
210
+ * a wheel up over a transcript already at its top, which cannot go up.
211
+ */
212
+ function onWheel(e) {
213
+ if (e.deltaY < 0 && pinned && !e.ctrlKey && !e.defaultPrevented && log.scrollTop > 0
214
+ && !innerTakesWheelUp(e.target)) setPinned(false);
215
+ }
216
+ function onJump() { setPinned(true); toBottom(); }
217
+ log.addEventListener('scroll', onScroll);
218
+ log.addEventListener('wheel', onWheel, { passive: true });
219
+ jump.addEventListener('click', onJump);
220
+
221
+ // ── messages ─────────────────────────────────────────────────────────
222
+ /** A fresh id, never one `taken` holds. */
223
+ const newId = (taken = (id) => entries.has(id)) => {
224
+ let id;
225
+ do id = `m${nextId++}`; while (taken(id));
226
+ return id;
227
+ };
228
+ const duplicate = (id) => new Error(`conversation view: duplicate message id "${id}"`);
229
+ const roleClass = (role) => String(role ?? '').toLowerCase().replace(/[^a-z0-9-]+/g, '-') || 'none';
230
+ const snapshot = (message) => ({ ...message });
231
+
232
+ function drawBody(entry, streaming = entry.message.id === streamId) {
233
+ const { message, body } = entry;
234
+ body.replaceChildren();
235
+ body.classList.remove('twm-chat__body--plain');
236
+ if (render) {
237
+ try {
238
+ render(snapshot(message), body, { streaming });
239
+ return;
240
+ } catch (error) {
241
+ body.replaceChildren();
242
+ if (onRenderError) {
243
+ try { onRenderError(error, snapshot(message)); } catch { /* the view keeps working */ }
244
+ }
245
+ }
246
+ }
247
+ body.classList.add('twm-chat__body--plain');
248
+ body.textContent = message.text;
249
+ }
250
+
251
+ /**
252
+ * One entry, drawn, and added to `into` (the transcript unless a
253
+ * replaceAll is building its own). Throws on an id `into` already holds,
254
+ * before anything is changed.
255
+ */
256
+ function build(spec, { into = entries, taken, streaming = false } = {}) {
257
+ const message = {
258
+ ...spec,
259
+ id: spec?.id ?? newId(taken ?? ((id) => into.has(id))),
260
+ role: spec?.role ?? 'assistant',
261
+ text: String(spec?.text ?? ''),
262
+ };
263
+ if (into.has(message.id)) throw duplicate(message.id);
264
+ const row = el('div', `twm-chat__msg twm-chat__msg--${roleClass(message.role)}`);
265
+ if (streaming) row.classList.add('twm-chat__msg--streaming');
266
+ const body = el('div', 'twm-chat__body');
267
+ const extra = el('div', 'twm-chat__extra');
268
+ row.append(body, extra);
269
+ const entry = { message, row, body, extra };
270
+ into.set(message.id, entry);
271
+ drawBody(entry, streaming);
272
+ return entry;
273
+ }
274
+
275
+ function append(message) {
276
+ const entry = build(message);
277
+ log.append(entry.row);
278
+ settle();
279
+ return entry.message.id;
280
+ }
281
+
282
+ /** All or nothing: a repeated id throws before the transcript is touched. No list (undefined or null) empties it. */
283
+ function replaceAll(messages) {
284
+ const list = [...(messages ?? [])];
285
+ const given = new Set();
286
+ for (const m of list) {
287
+ if (m?.id == null) continue;
288
+ if (given.has(m.id)) throw duplicate(m.id);
289
+ given.add(m.id);
290
+ }
291
+ const next = new Map();
292
+ const taken = (id) => given.has(id) || next.has(id);
293
+ const frag = doc.createDocumentFragment();
294
+ for (const m of list) frag.append(build(m, { into: next, taken }).row);
295
+ streamId = null;
296
+ entries.clear();
297
+ for (const [id, entry] of next) entries.set(id, entry);
298
+ log.replaceChildren(frag);
299
+ setPinned(true);
300
+ toBottom();
301
+ }
302
+
303
+ function clear() { replaceAll([]); }
304
+
305
+ // ── streaming ────────────────────────────────────────────────────────
306
+ /**
307
+ * Refused (a throw, nothing changed, an open stream left open) on an id
308
+ * already present. No message (undefined or null) streams an empty
309
+ * assistant message, as `append` treats them.
310
+ */
311
+ function beginStream(message) {
312
+ if (message?.id != null && entries.has(message.id)) throw duplicate(message.id);
313
+ if (streamId !== null) endStream();
314
+ const entry = build({ role: 'assistant', text: '', ...(message ?? {}) }, { streaming: true });
315
+ streamId = entry.message.id;
316
+ log.append(entry.row);
317
+ settle();
318
+ return streamId;
319
+ }
320
+
321
+ function appendChunk(chunk) {
322
+ const entry = streamId !== null ? entries.get(streamId) : null;
323
+ if (!entry) return;
324
+ entry.message.text += String(chunk ?? '');
325
+ drawBody(entry);
326
+ settle();
327
+ }
328
+
329
+ function endStream() {
330
+ const entry = streamId !== null ? entries.get(streamId) : null;
331
+ streamId = null;
332
+ if (!entry) return null;
333
+ entry.row.classList.remove('twm-chat__msg--streaming');
334
+ drawBody(entry);
335
+ settle();
336
+ return entry.message.id;
337
+ }
338
+
339
+ // ── the composer ─────────────────────────────────────────────────────
340
+ function grow() {
341
+ input.style.height = 'auto';
342
+ input.style.height = `${Math.min(input.scrollHeight, composerMaxHeight)}px`;
343
+ input.style.overflowY = input.scrollHeight > composerMaxHeight ? 'auto' : 'hidden';
344
+ }
345
+
346
+ function drawSend() {
347
+ send.replaceChildren(icon(busy ? 'stop' : 'send'));
348
+ send.setAttribute('aria-label', busy ? text.cancel : text.send);
349
+ send.title = busy ? text.cancel : text.send;
350
+ send.classList.toggle('twm-chat__send--cancel', busy);
351
+ root.classList.toggle('twm-chat--busy', busy);
352
+ }
353
+
354
+ function submit() {
355
+ if (busy) return;
356
+ const value = input.value;
357
+ if (!value.trim()) return;
358
+ input.value = '';
359
+ grow();
360
+ onSubmit?.(value);
361
+ }
362
+
363
+ function cancel() {
364
+ if (!busy || cancelled) return;
365
+ cancelled = true;
366
+ onCancel?.();
367
+ }
368
+
369
+ /** Enter submits; Shift+Enter (and Ctrl/Meta/Alt+Enter) is the browser's to handle. */
370
+ function onInputKey(e) {
371
+ if (e.isComposing || e.key !== 'Enter') return;
372
+ if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
373
+ e.preventDefault();
374
+ submit();
375
+ }
376
+ /**
377
+ * Esc while busy cancels wherever focus is inside the view, and it is the
378
+ * view's alone: it goes no further, so a window or panel holding the view
379
+ * that closes on Escape stays open — on the cancelling Esc and on any
380
+ * repeat in the same busy period. An Esc a handler inside the view
381
+ * already consumed (defaultPrevented) is that handler's: it neither
382
+ * cancels nor is stopped.
383
+ */
384
+ function onRootKey(e) {
385
+ if (e.isComposing || e.key !== 'Escape' || !busy || e.defaultPrevented) return;
386
+ e.preventDefault();
387
+ e.stopPropagation();
388
+ cancel();
389
+ }
390
+ input.addEventListener('keydown', onInputKey);
391
+ input.addEventListener('input', grow);
392
+ root.addEventListener('keydown', onRootKey);
393
+ send.addEventListener('click', () => (busy ? cancel() : submit()));
394
+ drawSend();
395
+
396
+ return {
397
+ root,
398
+ append,
399
+ replaceAll,
400
+ clear,
401
+ beginStream,
402
+ appendChunk,
403
+ endStream,
404
+ /** A copy of every message, in order. */
405
+ messages: () => [...entries.values()].map((e) => snapshot(e.message)),
406
+ /** The extra slot below a message's body, or null for an unknown id. */
407
+ slot: (id) => entries.get(id)?.extra ?? null,
408
+ /** The id of the message being streamed, or null. */
409
+ streamingId: () => streamId,
410
+ isPinned: () => pinned,
411
+ getScrollTop: () => log.scrollTop,
412
+ /** Scrolls the transcript; pinned again only when that lands at the end. */
413
+ setScrollTop(px) {
414
+ log.scrollTop = px;
415
+ lastScrollTop = log.scrollTop;
416
+ setPinned(distanceFromEnd() <= pinThreshold);
417
+ },
418
+ scrollToBottom: onJump,
419
+ /** `true` turns Send into Cancel; every busy period may cancel once. */
420
+ setBusy(value) {
421
+ if (busy === !!value) return;
422
+ busy = !!value;
423
+ cancelled = false;
424
+ drawSend();
425
+ },
426
+ isBusy: () => busy,
427
+ getText: () => input.value,
428
+ setText(value) { input.value = String(value ?? ''); grow(); },
429
+ focus: () => input.focus(),
430
+ destroy() {
431
+ if (destroyed) return;
432
+ destroyed = true;
433
+ streamId = null;
434
+ busy = false;
435
+ cancelled = false;
436
+ entries.clear();
437
+ root.remove();
438
+ },
439
+ };
440
+ }