flexdesk 0.16.1 → 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.
- package/CHANGELOG.md +93 -0
- package/LICENSE +0 -0
- package/README.md +2217 -42
- package/THIRD_PARTY_LICENSES.md +0 -0
- package/css/base.css +1834 -75
- package/css/flexdesk.css +3146 -164
- package/css/overrides.css +16 -0
- package/css/reset.css +0 -0
- package/css/tokens.css +0 -0
- package/dist/canvas.js +6 -239
- package/dist/canvas.js.map +3 -3
- package/dist/charts.js +709 -18
- package/dist/charts.js.map +4 -4
- package/dist/chunk-3PHCPZHT.js +0 -0
- package/dist/chunk-3PHCPZHT.js.map +0 -0
- package/dist/chunk-4A7SJC3T.js +503 -0
- package/dist/chunk-4A7SJC3T.js.map +7 -0
- package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
- package/dist/chunk-CVMCZZX2.js.map +7 -0
- package/dist/chunk-D3COLZZ2.js +1311 -0
- package/dist/chunk-D3COLZZ2.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +0 -0
- package/dist/chunk-FOOS3T5L.js.map +0 -0
- package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
- package/dist/chunk-GOQLR5WR.js.map +7 -0
- package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
- package/dist/chunk-HNU6MM2B.js.map +7 -0
- package/dist/chunk-HYJYYEPK.js +375 -0
- package/dist/chunk-HYJYYEPK.js.map +7 -0
- package/dist/chunk-JKZLNKG6.js +2041 -0
- package/dist/chunk-JKZLNKG6.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +0 -0
- package/dist/chunk-JYWURG5T.js.map +0 -0
- package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
- package/dist/chunk-MCIXFNVF.js.map +7 -0
- package/dist/chunk-N7HC3ADV.js +30 -0
- package/dist/chunk-N7HC3ADV.js.map +7 -0
- package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
- package/dist/chunk-Q36EPT2R.js.map +7 -0
- package/dist/chunk-QWQJUDPD.js +610 -0
- package/dist/chunk-QWQJUDPD.js.map +7 -0
- package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
- package/dist/chunk-UURZ37SD.js.map +7 -0
- package/dist/{chunk-B5Q53LAJ.js → chunk-UWO43BZU.js} +2 -2
- package/dist/chunk-UWO43BZU.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +0 -0
- package/dist/chunk-WVFGV5FT.js.map +0 -0
- package/dist/core.js +170 -118
- package/dist/core.js.map +2 -2
- package/dist/editor.js +156 -70
- package/dist/editor.js.map +2 -2
- package/dist/flexdesk.css +3146 -164
- package/dist/flow.js +2789 -306
- package/dist/flow.js.map +4 -4
- package/dist/host.js +109 -3
- package/dist/host.js.map +3 -3
- package/dist/reset.css +0 -0
- package/dist/tile_registry-6WZPTUZV.js +0 -0
- package/dist/tile_registry-6WZPTUZV.js.map +0 -0
- package/dist/tiles.js +657 -234
- package/dist/tiles.js.map +4 -4
- package/dist/tokens.css +0 -0
- package/dist/widgets.js +3252 -2124
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +1227 -347
- package/dist/wm.js.map +4 -4
- package/package.json +3 -1
- package/src/canvas/canvas_adapter.js +5 -0
- package/src/canvas/edge_router.js +0 -0
- package/src/canvas/graph/connector_router.js +0 -0
- package/src/canvas/graph/history_service.js +0 -0
- package/src/canvas/graph/node_platform.js +0 -0
- package/src/canvas/index.js +1 -0
- package/src/canvas/layered_layout.js +244 -0
- package/src/charts/chart_types.js +0 -0
- package/src/charts/data_series_plot_window.js +0 -0
- package/src/charts/downsample.js +2 -2
- package/src/charts/plot_config.js +53 -29
- package/src/charts/plot_config_editor.js +627 -0
- package/src/charts/plot_grid.js +449 -0
- package/src/charts/plot_popout_window.js +103 -317
- package/src/charts/plotly_wrapper.js +22 -2
- package/src/charts/series_transforms.js +206 -0
- package/src/core/event_bus.js +0 -0
- package/src/core/logging.js +1 -1
- package/src/core/settings.js +105 -19
- package/src/core/state_guard.js +93 -51
- package/src/core/state_guard_config.js +85 -74
- package/src/core/state_machine.js +0 -0
- package/src/editor/editor_pane.js +3 -3
- package/src/editor/monaco_editor_factory.js +124 -60
- package/src/editor/monaco_loader.js +103 -28
- package/src/editor/notebook_search.js +0 -0
- package/src/editor/notebook_tab_bar.js +38 -52
- package/src/editor/notebook_undo_manager.js +0 -0
- package/src/editor/split_pane_container.js +6 -3
- package/src/flow/canvas_editor/connections.js +90 -0
- package/src/flow/canvas_editor/editor.js +2351 -153
- package/src/flow/canvas_editor/index.js +2 -1
- package/src/flow/canvas_editor/layout.js +119 -12
- package/src/flow/canvas_editor/strings.js +80 -0
- package/src/flow/kit/always_before.js +0 -0
- package/src/flow/kit/catalogue.js +97 -3
- package/src/flow/kit/chip_input.js +0 -0
- package/src/flow/kit/dom.js +0 -0
- package/src/flow/kit/findings.js +0 -0
- package/src/flow/kit/history.js +0 -0
- package/src/flow/kit/index.js +3 -3
- package/src/flow/kit/keys.js +7 -1
- package/src/flow/kit/logic_graph.js +97 -15
- package/src/flow/kit/references.js +0 -0
- package/src/flow/kit/settings_panel.js +0 -0
- package/src/flow/kit/settings_schema.js +2 -1
- package/src/flow/kit/step_picker.js +0 -0
- package/src/flow/kit/strings.js +3 -1
- package/src/flow/kit/value_picker.js +0 -0
- package/src/flow/kit/widgets.js +86 -109
- package/src/flow/lanes/editor.js +136 -14
- package/src/flow/lanes/index.js +3 -1
- package/src/flow/lanes/layout.js +0 -0
- package/src/flow/lanes/pipeline.js +44 -2
- package/src/flow/lanes/preview.js +0 -0
- package/src/flow/lanes/strings.js +1 -0
- package/src/flow/outline/editor.js +0 -0
- package/src/flow/outline/index.js +0 -0
- package/src/flow/outline/mapping.js +0 -0
- package/src/flow/outline/menu.js +0 -0
- package/src/flow/outline/operations.js +0 -0
- package/src/flow/outline/recognise.js +0 -0
- package/src/flow/outline/strings.js +0 -0
- package/src/help/help_modal.js +168 -42
- package/src/help/help_registry.js +12 -1
- package/src/host/external_links.js +0 -0
- package/src/host/file_picker.js +117 -0
- package/src/host/host.js +10 -2
- package/src/host/photino_host.js +9 -0
- package/src/host/photino_host.md +21 -3
- package/src/host/pywebview_host.js +10 -0
- package/src/tiles/chart_tiles.js +53 -245
- package/src/tiles/config_schema.js +8 -0
- package/src/tiles/figure_tile.js +322 -0
- package/src/tiles/layout_persistence.js +3 -3
- package/src/tiles/list_tiles.js +7 -7
- package/src/tiles/scope_picker.js +0 -0
- package/src/tiles/tile_base.js +1 -1
- package/src/tiles/tile_config_form.js +442 -0
- package/src/tiles/tile_grid.js +2 -0
- package/src/tiles/tile_registry.js +0 -0
- package/src/tiling/bottom_panel_tabs.js +67 -99
- package/src/tiling/command_palette.js +64 -19
- package/src/tiling/content_registry.js +4 -1
- package/src/tiling/desktops.js +11 -1
- package/src/tiling/entity_sources.js +0 -0
- package/src/tiling/keymap.js +403 -128
- package/src/tiling/kind_taxonomy.js +78 -0
- package/src/tiling/landing_table.js +0 -0
- package/src/tiling/loading_overlay.js +0 -0
- package/src/tiling/nav_panel.js +59 -10
- package/src/tiling/page_factory.js +5 -5
- package/src/tiling/panel_availability.js +49 -0
- package/src/tiling/panel_keys.js +0 -0
- package/src/tiling/shell.js +114 -12
- package/src/tiling/tab_strip.js +0 -0
- package/src/tiling/tile_breadcrumb.js +0 -0
- package/src/tiling/tile_renderer.js +105 -9
- package/src/tiling/tile_tab_menu.js +0 -0
- package/src/tiling/tile_tree.js +0 -0
- package/src/tiling/wm.js +562 -30
- package/src/tiling/zoom.js +0 -0
- package/src/ui/base/component_base.js +0 -0
- package/src/ui/base/controller_base.js +0 -0
- package/src/ui/base/page_base.js +0 -0
- package/src/ui/components/about_dialog.js +0 -0
- package/src/ui/components/action_dropdown.js +30 -2
- package/src/ui/components/alert_log.js +0 -0
- package/src/ui/components/alerts_pane.js +0 -0
- package/src/ui/components/attribute_list_editor.js +0 -0
- package/src/ui/components/autocomplete_field.js +0 -0
- package/src/ui/components/cell_editor_host.js +211 -0
- package/src/ui/components/cell_grid.js +648 -0
- package/src/ui/components/cell_selection.js +220 -0
- package/src/ui/components/choice_controls.js +351 -0
- package/src/ui/components/color_field.js +211 -0
- package/src/ui/components/color_list_field.js +210 -0
- package/src/ui/components/column_fit.js +0 -0
- package/src/ui/components/column_groups.js +314 -0
- package/src/ui/components/column_order.js +142 -0
- package/src/ui/components/computing_status_window.js +98 -25
- package/src/ui/components/confirm_dialog.js +81 -38
- package/src/ui/components/context_menu.js +2 -2
- package/src/ui/components/conversation_view.js +440 -0
- package/src/ui/components/crosstab.js +289 -0
- package/src/ui/components/data_table.js +601 -62
- package/src/ui/components/detail_header.js +22 -11
- package/src/ui/components/document_outline.js +397 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/fact_table.js +0 -0
- package/src/ui/components/fill_handle.js +322 -0
- package/src/ui/components/gallery_picker.js +19 -8
- package/src/ui/components/header_actions.js +55 -0
- package/src/ui/components/inline_renamer.js +0 -0
- package/src/ui/components/job_progress.js +51 -0
- package/src/ui/components/managed_window.js +27 -4
- package/src/ui/components/menu_bar.js +745 -0
- package/src/ui/components/modal.js +449 -75
- package/src/ui/components/notification_history.js +3 -3
- package/src/ui/components/progress.js +483 -0
- package/src/ui/components/record_list.js +0 -0
- package/src/ui/components/row_activation.js +0 -0
- package/src/ui/components/section_stack.js +298 -0
- package/src/ui/components/series.js +173 -0
- package/src/ui/components/settings_view.js +634 -0
- package/src/ui/components/slide_out_panel.js +43 -6
- package/src/ui/components/slider_field.js +275 -18
- package/src/ui/components/sortable_list.js +0 -0
- package/src/ui/components/staged_edits.js +343 -0
- package/src/ui/components/staged_history.js +172 -0
- package/src/ui/components/table_filter_chips.js +0 -0
- package/src/ui/components/table_state_store.js +0 -0
- package/src/ui/components/table_structure.js +182 -0
- package/src/ui/components/table_values.js +0 -0
- package/src/ui/components/tabs.js +497 -0
- package/src/ui/components/tag_input.js +215 -0
- package/src/ui/components/toast.js +25 -8
- package/src/ui/components/toggle_field.js +175 -0
- package/src/ui/components/tree_view.js +55 -34
- package/src/ui/components/window_taskbar.js +0 -0
- package/src/ui/components/wizard.js +563 -0
- package/src/ui/controllers/window_chrome_controller.js +2 -2
- package/src/ui/notification_center.js +375 -119
- package/src/ui/utils/auto_scrollbars.js +0 -0
- package/src/ui/utils/markdown.js +709 -0
- package/src/ui/utils/native_context_menu.js +0 -0
- package/src/ui/utils/overlay_scrollbar.js +16 -12
- package/src/ui/utils/raf_resize_observer.js +0 -0
- package/src/ui/utils/scroll_lock.js +0 -0
- package/src/ui/utils/strip_scroll.js +55 -0
- package/src/ui/utils/tooltip_service.js +0 -8
- package/dist/chunk-6JSOVNID.js.map +0 -7
- package/dist/chunk-B5Q53LAJ.js.map +0 -7
- package/dist/chunk-FBGP5KN5.js.map +0 -7
- package/dist/chunk-FL5KFNQH.js.map +0 -7
- package/dist/chunk-NLTOY57M.js.map +0 -7
- package/dist/chunk-NNFTETK3.js +0 -633
- package/dist/chunk-NNFTETK3.js.map +0 -7
- package/dist/chunk-O5OHMWBB.js +0 -284
- package/dist/chunk-O5OHMWBB.js.map +0 -7
- package/dist/chunk-RQM5Z2MU.js.map +0 -7
- package/dist/chunk-SN4LSOAR.js.map +0 -7
- package/dist/chunk-W6FBJLPW.js +0 -367
- package/dist/chunk-W6FBJLPW.js.map +0 -7
- 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 /
|
|
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 +=
|
|
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 (
|
|
196
|
+
// ─── showRunProgressDialog (custom build) ───────────────────────────
|
|
193
197
|
//
|
|
194
|
-
// This one exposes an imperative API (`update`, `
|
|
195
|
-
// `close`) that the openModal/openForm/openConfirm
|
|
196
|
-
// — they're one-shot promise-returning. Keep the direct
|
|
197
|
-
// build, but it still gets the TUI chrome + focus trap for free
|
|
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
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
327
|
+
if (finished) return;
|
|
279
328
|
messageEl.textContent = msg;
|
|
280
329
|
},
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3
|
-
*
|
|
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
|
+
}
|