flexdesk 0.16.1 → 0.17.1

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 (262) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2435 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/canvas.js +18 -0
  6. package/charts.js +21 -0
  7. package/core.js +14 -0
  8. package/css/base.css +1834 -75
  9. package/css/flexdesk.css +3165 -159
  10. package/css/overrides.css +16 -0
  11. package/css/reset.css +0 -0
  12. package/css/tokens.css +0 -0
  13. package/dist/canvas.js +6 -239
  14. package/dist/canvas.js.map +3 -3
  15. package/dist/charts.js +707 -18
  16. package/dist/charts.js.map +4 -4
  17. package/dist/chunk-3PHCPZHT.js +0 -0
  18. package/dist/chunk-3PHCPZHT.js.map +0 -0
  19. package/dist/chunk-4A7SJC3T.js +503 -0
  20. package/dist/chunk-4A7SJC3T.js.map +7 -0
  21. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  22. package/dist/chunk-CVMCZZX2.js.map +7 -0
  23. package/dist/chunk-D3COLZZ2.js +1311 -0
  24. package/dist/chunk-D3COLZZ2.js.map +7 -0
  25. package/dist/chunk-FOOS3T5L.js +0 -0
  26. package/dist/chunk-FOOS3T5L.js.map +0 -0
  27. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  28. package/dist/chunk-GOQLR5WR.js.map +7 -0
  29. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  30. package/dist/chunk-HNU6MM2B.js.map +7 -0
  31. package/dist/chunk-HYJYYEPK.js +375 -0
  32. package/dist/chunk-HYJYYEPK.js.map +7 -0
  33. package/dist/chunk-JKZLNKG6.js +2041 -0
  34. package/dist/chunk-JKZLNKG6.js.map +7 -0
  35. package/dist/chunk-JYWURG5T.js +0 -0
  36. package/dist/chunk-JYWURG5T.js.map +0 -0
  37. package/dist/{chunk-FBGP5KN5.js → chunk-LWV23YVE.js} +5073 -532
  38. package/dist/chunk-LWV23YVE.js.map +7 -0
  39. package/dist/chunk-N7HC3ADV.js +30 -0
  40. package/dist/chunk-N7HC3ADV.js.map +7 -0
  41. package/dist/{chunk-RQM5Z2MU.js → chunk-RMTRWWEO.js} +633 -311
  42. package/dist/chunk-RMTRWWEO.js.map +7 -0
  43. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  44. package/dist/chunk-UURZ37SD.js.map +7 -0
  45. package/dist/{chunk-B5Q53LAJ.js → chunk-UWO43BZU.js} +2 -2
  46. package/dist/chunk-UWO43BZU.js.map +7 -0
  47. package/dist/chunk-WVFGV5FT.js +0 -0
  48. package/dist/chunk-WVFGV5FT.js.map +0 -0
  49. package/dist/core.js +170 -118
  50. package/dist/core.js.map +2 -2
  51. package/dist/editor.js +156 -70
  52. package/dist/editor.js.map +2 -2
  53. package/dist/flexdesk.css +3165 -159
  54. package/dist/flow.js +2789 -306
  55. package/dist/flow.js.map +4 -4
  56. package/dist/host.js +109 -3
  57. package/dist/host.js.map +3 -3
  58. package/dist/reset.css +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js +0 -0
  60. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  61. package/dist/tiles.js +657 -234
  62. package/dist/tiles.js.map +4 -4
  63. package/dist/tokens.css +0 -0
  64. package/dist/widgets.js +3290 -2124
  65. package/dist/widgets.js.map +4 -4
  66. package/dist/wm.js +1227 -347
  67. package/dist/wm.js.map +4 -4
  68. package/editor.js +20 -0
  69. package/flow.js +21 -0
  70. package/host.js +14 -0
  71. package/package.json +12 -1
  72. package/src/canvas/canvas_adapter.js +5 -0
  73. package/src/canvas/edge_router.js +0 -0
  74. package/src/canvas/graph/connector_router.js +0 -0
  75. package/src/canvas/graph/history_service.js +0 -0
  76. package/src/canvas/graph/node_platform.js +0 -0
  77. package/src/canvas/index.js +1 -0
  78. package/src/canvas/layered_layout.js +244 -0
  79. package/src/charts/chart_types.js +0 -0
  80. package/src/charts/data_series_plot_window.js +0 -0
  81. package/src/charts/downsample.js +2 -2
  82. package/src/charts/plot_config.js +53 -29
  83. package/src/charts/plot_config_editor.js +627 -0
  84. package/src/charts/plot_grid.js +449 -0
  85. package/src/charts/plot_popout_window.js +103 -317
  86. package/src/charts/plotly_wrapper.js +22 -2
  87. package/src/charts/series_transforms.js +206 -0
  88. package/src/core/event_bus.js +0 -0
  89. package/src/core/logging.js +1 -1
  90. package/src/core/settings.js +105 -19
  91. package/src/core/state_guard.js +93 -51
  92. package/src/core/state_guard_config.js +85 -74
  93. package/src/core/state_machine.js +0 -0
  94. package/src/editor/editor_pane.js +3 -3
  95. package/src/editor/monaco_editor_factory.js +124 -60
  96. package/src/editor/monaco_loader.js +103 -28
  97. package/src/editor/notebook_search.js +0 -0
  98. package/src/editor/notebook_tab_bar.js +38 -52
  99. package/src/editor/notebook_undo_manager.js +0 -0
  100. package/src/editor/split_pane_container.js +6 -3
  101. package/src/flow/canvas_editor/connections.js +90 -0
  102. package/src/flow/canvas_editor/editor.js +2351 -153
  103. package/src/flow/canvas_editor/index.js +2 -1
  104. package/src/flow/canvas_editor/layout.js +119 -12
  105. package/src/flow/canvas_editor/strings.js +80 -0
  106. package/src/flow/kit/always_before.js +0 -0
  107. package/src/flow/kit/catalogue.js +97 -3
  108. package/src/flow/kit/chip_input.js +0 -0
  109. package/src/flow/kit/dom.js +0 -0
  110. package/src/flow/kit/findings.js +0 -0
  111. package/src/flow/kit/history.js +0 -0
  112. package/src/flow/kit/index.js +3 -3
  113. package/src/flow/kit/keys.js +7 -1
  114. package/src/flow/kit/logic_graph.js +97 -15
  115. package/src/flow/kit/references.js +0 -0
  116. package/src/flow/kit/settings_panel.js +0 -0
  117. package/src/flow/kit/settings_schema.js +2 -1
  118. package/src/flow/kit/step_picker.js +0 -0
  119. package/src/flow/kit/strings.js +3 -1
  120. package/src/flow/kit/value_picker.js +0 -0
  121. package/src/flow/kit/widgets.js +86 -109
  122. package/src/flow/lanes/editor.js +136 -14
  123. package/src/flow/lanes/index.js +3 -1
  124. package/src/flow/lanes/layout.js +0 -0
  125. package/src/flow/lanes/pipeline.js +44 -2
  126. package/src/flow/lanes/preview.js +0 -0
  127. package/src/flow/lanes/strings.js +1 -0
  128. package/src/flow/outline/editor.js +0 -0
  129. package/src/flow/outline/index.js +0 -0
  130. package/src/flow/outline/mapping.js +0 -0
  131. package/src/flow/outline/menu.js +0 -0
  132. package/src/flow/outline/operations.js +0 -0
  133. package/src/flow/outline/recognise.js +0 -0
  134. package/src/flow/outline/strings.js +0 -0
  135. package/src/help/help_modal.js +168 -42
  136. package/src/help/help_registry.js +12 -1
  137. package/src/host/external_links.js +0 -0
  138. package/src/host/file_picker.js +117 -0
  139. package/src/host/host.js +10 -2
  140. package/src/host/photino_host.js +9 -0
  141. package/src/host/photino_host.md +21 -3
  142. package/src/host/pywebview_host.js +10 -0
  143. package/src/tiles/chart_tiles.js +53 -245
  144. package/src/tiles/config_schema.js +8 -0
  145. package/src/tiles/figure_tile.js +322 -0
  146. package/src/tiles/layout_persistence.js +3 -3
  147. package/src/tiles/list_tiles.js +7 -7
  148. package/src/tiles/scope_picker.js +0 -0
  149. package/src/tiles/tile_base.js +1 -1
  150. package/src/tiles/tile_config_form.js +442 -0
  151. package/src/tiles/tile_grid.js +2 -0
  152. package/src/tiles/tile_registry.js +0 -0
  153. package/src/tiling/bottom_panel_tabs.js +67 -99
  154. package/src/tiling/command_palette.js +64 -19
  155. package/src/tiling/content_registry.js +4 -1
  156. package/src/tiling/desktops.js +11 -1
  157. package/src/tiling/entity_sources.js +0 -0
  158. package/src/tiling/keymap.js +403 -128
  159. package/src/tiling/kind_taxonomy.js +78 -0
  160. package/src/tiling/landing_table.js +0 -0
  161. package/src/tiling/loading_overlay.js +0 -0
  162. package/src/tiling/nav_panel.js +59 -10
  163. package/src/tiling/page_factory.js +5 -5
  164. package/src/tiling/panel_availability.js +49 -0
  165. package/src/tiling/panel_keys.js +0 -0
  166. package/src/tiling/shell.js +114 -12
  167. package/src/tiling/tab_strip.js +0 -0
  168. package/src/tiling/tile_breadcrumb.js +0 -0
  169. package/src/tiling/tile_renderer.js +105 -9
  170. package/src/tiling/tile_tab_menu.js +0 -0
  171. package/src/tiling/tile_tree.js +0 -0
  172. package/src/tiling/wm.js +562 -30
  173. package/src/tiling/zoom.js +0 -0
  174. package/src/ui/base/component_base.js +0 -0
  175. package/src/ui/base/controller_base.js +0 -0
  176. package/src/ui/base/page_base.js +0 -0
  177. package/src/ui/components/about_dialog.js +0 -0
  178. package/src/ui/components/action_dropdown.js +30 -2
  179. package/src/ui/components/alert_log.js +0 -0
  180. package/src/ui/components/alerts_pane.js +0 -0
  181. package/src/ui/components/attribute_list_editor.js +0 -0
  182. package/src/ui/components/autocomplete_field.js +0 -0
  183. package/src/ui/components/cell_editor_host.js +293 -0
  184. package/src/ui/components/cell_editors.js +480 -0
  185. package/src/ui/components/cell_grid.js +761 -0
  186. package/src/ui/components/cell_paste.js +243 -0
  187. package/src/ui/components/cell_selection.js +220 -0
  188. package/src/ui/components/choice_controls.js +351 -0
  189. package/src/ui/components/color_field.js +211 -0
  190. package/src/ui/components/color_list_field.js +210 -0
  191. package/src/ui/components/column_fit.js +0 -0
  192. package/src/ui/components/column_groups.js +314 -0
  193. package/src/ui/components/column_order.js +142 -0
  194. package/src/ui/components/computing_status_window.js +98 -25
  195. package/src/ui/components/confirm_dialog.js +81 -38
  196. package/src/ui/components/context_menu.js +2 -2
  197. package/src/ui/components/conversation_view.js +440 -0
  198. package/src/ui/components/crosstab.js +289 -0
  199. package/src/ui/components/data_table.js +696 -71
  200. package/src/ui/components/detail_header.js +22 -11
  201. package/src/ui/components/document_outline.js +397 -0
  202. package/src/ui/components/drag_reorder.js +0 -0
  203. package/src/ui/components/fact_table.js +0 -0
  204. package/src/ui/components/fill_handle.js +322 -0
  205. package/src/ui/components/gallery_picker.js +19 -8
  206. package/src/ui/components/header_actions.js +55 -0
  207. package/src/ui/components/inline_renamer.js +0 -0
  208. package/src/ui/components/job_progress.js +51 -0
  209. package/src/ui/components/managed_window.js +27 -4
  210. package/src/ui/components/menu_bar.js +745 -0
  211. package/src/ui/components/modal.js +449 -75
  212. package/src/ui/components/notification_history.js +3 -3
  213. package/src/ui/components/progress.js +483 -0
  214. package/src/ui/components/record_list.js +0 -0
  215. package/src/ui/components/row_activation.js +0 -0
  216. package/src/ui/components/section_stack.js +298 -0
  217. package/src/ui/components/series.js +173 -0
  218. package/src/ui/components/settings_view.js +634 -0
  219. package/src/ui/components/slide_out_panel.js +43 -6
  220. package/src/ui/components/slider_field.js +275 -18
  221. package/src/ui/components/sortable_list.js +0 -0
  222. package/src/ui/components/staged_edits.js +361 -0
  223. package/src/ui/components/staged_history.js +172 -0
  224. package/src/ui/components/table_filter_chips.js +0 -0
  225. package/src/ui/components/table_state_store.js +0 -0
  226. package/src/ui/components/table_structure.js +182 -0
  227. package/src/ui/components/table_values.js +0 -0
  228. package/src/ui/components/tabs.js +497 -0
  229. package/src/ui/components/tag_input.js +215 -0
  230. package/src/ui/components/toast.js +25 -8
  231. package/src/ui/components/toggle_field.js +175 -0
  232. package/src/ui/components/tree_view.js +55 -34
  233. package/src/ui/components/tsv.js +74 -0
  234. package/src/ui/components/window_taskbar.js +0 -0
  235. package/src/ui/components/wizard.js +563 -0
  236. package/src/ui/controllers/window_chrome_controller.js +2 -2
  237. package/src/ui/notification_center.js +375 -119
  238. package/src/ui/utils/auto_scrollbars.js +0 -0
  239. package/src/ui/utils/markdown.js +709 -0
  240. package/src/ui/utils/native_context_menu.js +0 -0
  241. package/src/ui/utils/overlay_scrollbar.js +16 -12
  242. package/src/ui/utils/raf_resize_observer.js +0 -0
  243. package/src/ui/utils/scroll_lock.js +0 -0
  244. package/src/ui/utils/strip_scroll.js +55 -0
  245. package/src/ui/utils/tooltip_service.js +0 -8
  246. package/tiles.js +27 -0
  247. package/widgets.js +69 -0
  248. package/wm.js +28 -0
  249. package/dist/chunk-6JSOVNID.js.map +0 -7
  250. package/dist/chunk-B5Q53LAJ.js.map +0 -7
  251. package/dist/chunk-FBGP5KN5.js.map +0 -7
  252. package/dist/chunk-FL5KFNQH.js.map +0 -7
  253. package/dist/chunk-NLTOY57M.js.map +0 -7
  254. package/dist/chunk-NNFTETK3.js +0 -633
  255. package/dist/chunk-NNFTETK3.js.map +0 -7
  256. package/dist/chunk-O5OHMWBB.js +0 -284
  257. package/dist/chunk-O5OHMWBB.js.map +0 -7
  258. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  259. package/dist/chunk-SN4LSOAR.js.map +0 -7
  260. package/dist/chunk-W6FBJLPW.js +0 -367
  261. package/dist/chunk-W6FBJLPW.js.map +0 -7
  262. package/src/ui/controllers/panel_state_machine.js +0 -634
@@ -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
+ }