@perspective-dev/viewer 5.1.0 → 5.2.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 (175) hide show
  1. package/dist/cdn/perspective-viewer.js +9 -9
  2. package/dist/cdn/perspective-viewer.js.map +4 -4
  3. package/dist/css/botanical.css +1 -1
  4. package/dist/css/dracula.css +1 -1
  5. package/dist/css/gruvbox-dark.css +1 -1
  6. package/dist/css/gruvbox.css +1 -1
  7. package/dist/css/intl/de.css +1 -1
  8. package/dist/css/intl/es.css +1 -1
  9. package/dist/css/intl/fr.css +1 -1
  10. package/dist/css/intl/ja.css +1 -1
  11. package/dist/css/intl/pt.css +1 -1
  12. package/dist/css/intl/zh.css +1 -1
  13. package/dist/css/intl.css +1 -1
  14. package/dist/css/monokai.css +1 -1
  15. package/dist/css/phosphor.css +1 -1
  16. package/dist/css/pro-dark.css +1 -1
  17. package/dist/css/pro.css +1 -1
  18. package/dist/css/solarized-dark.css +1 -1
  19. package/dist/css/solarized.css +1 -1
  20. package/dist/css/themes.css +1 -1
  21. package/dist/css/vaporwave.css +1 -1
  22. package/dist/docs/perspective-docs.json +1 -0
  23. package/dist/esm/column-format.d.ts +19 -28
  24. package/dist/esm/perspective-viewer.d.ts +2 -0
  25. package/dist/esm/perspective-viewer.inline.js +8 -8
  26. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  27. package/dist/esm/perspective-viewer.js +9 -9
  28. package/dist/esm/perspective-viewer.js.map +4 -4
  29. package/dist/esm/providers.d.ts +79 -0
  30. package/dist/esm/ts-rs/CompactDisplay.d.ts +5 -0
  31. package/dist/esm/ts-rs/CurrencyCode.d.ts +1 -0
  32. package/dist/esm/ts-rs/CurrencyDisplay.d.ts +1 -0
  33. package/dist/esm/ts-rs/CurrencyNumberFormatStyle.d.ts +8 -0
  34. package/dist/esm/ts-rs/CurrencySign.d.ts +1 -0
  35. package/dist/esm/ts-rs/CustomDatetimeFormat.d.ts +1 -0
  36. package/dist/esm/ts-rs/CustomDatetimeStyleConfig.d.ts +54 -0
  37. package/dist/esm/ts-rs/CustomNumberFormatConfig.d.ts +34 -0
  38. package/dist/esm/ts-rs/DatetimeColorMode.d.ts +1 -0
  39. package/dist/esm/ts-rs/DatetimeFormatType.d.ts +6 -0
  40. package/dist/esm/ts-rs/FormatMode.d.ts +1 -0
  41. package/dist/esm/ts-rs/FormatUnit.d.ts +1 -0
  42. package/dist/esm/ts-rs/Notation.d.ts +17 -0
  43. package/dist/esm/ts-rs/NumberFormatStyle.d.ts +18 -0
  44. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +41 -1
  45. package/dist/esm/ts-rs/PluginStaticConfig.d.ts +35 -0
  46. package/dist/esm/ts-rs/RestoreOptions.d.ts +19 -0
  47. package/dist/esm/ts-rs/RoundingMode.d.ts +1 -0
  48. package/dist/esm/ts-rs/RoundingPriority.d.ts +1 -0
  49. package/dist/esm/ts-rs/SignDisplay.d.ts +1 -0
  50. package/dist/esm/ts-rs/SimpleDatetimeFormat.d.ts +1 -0
  51. package/dist/esm/ts-rs/SimpleDatetimeStyleConfig.d.ts +24 -0
  52. package/dist/esm/ts-rs/SplitRollupMode.d.ts +9 -0
  53. package/dist/esm/ts-rs/StringColorMode.d.ts +1 -0
  54. package/dist/esm/ts-rs/TrailingZeroDisplay.d.ts +1 -0
  55. package/dist/esm/ts-rs/Unit.d.ts +1 -0
  56. package/dist/esm/ts-rs/UnitDisplay.d.ts +1 -0
  57. package/dist/esm/ts-rs/UnitNumberFormatStyle.d.ts +6 -0
  58. package/dist/esm/ts-rs/UseGrouping.d.ts +1 -0
  59. package/dist/esm/ts-rs/ViewerConfig.d.ts +41 -1
  60. package/dist/esm/ts-rs/ViewerConfigInitial.d.ts +175 -0
  61. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +63 -0
  62. package/dist/esm/ts-rs/WindowSpec.d.ts +3 -2
  63. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +7 -6
  64. package/dist/wasm/perspective-viewer.d.ts +134 -25
  65. package/dist/wasm/perspective-viewer.js +367 -124
  66. package/dist/wasm/perspective-viewer.wasm +0 -0
  67. package/dist/wasm/perspective-viewer.wasm.d.ts +18 -15
  68. package/package.json +2 -1
  69. package/src/css/_viewer_bundle.css +1 -0
  70. package/src/css/column-selector.css +8 -0
  71. package/src/css/config-selector.css +38 -3
  72. package/src/css/form/code-editor.css +2 -3
  73. package/src/css/form/mirrored-textarea.css +81 -0
  74. package/src/css/viewer.css +328 -0
  75. package/src/rust/agent/client/mod.rs +19 -0
  76. package/src/rust/agent/client/protocol.rs +320 -0
  77. package/src/rust/agent/client/transport.rs +287 -0
  78. package/src/rust/agent/client/turn.rs +174 -0
  79. package/src/rust/agent/config.rs +159 -0
  80. package/src/rust/agent/docs.rs +293 -0
  81. package/src/rust/agent/markdown/block.rs +509 -0
  82. package/src/rust/agent/markdown/entity.rs +52 -0
  83. package/src/rust/agent/markdown/events.rs +71 -0
  84. package/src/rust/agent/markdown/inline.rs +497 -0
  85. package/src/rust/agent/markdown/mod.rs +466 -0
  86. package/src/rust/agent/mod.rs +23 -0
  87. package/src/rust/agent/preamble.md +16 -0
  88. package/src/rust/agent/runtime.rs +201 -0
  89. package/src/rust/agent/slot.rs +236 -0
  90. package/src/rust/agent/tools.rs +1110 -0
  91. package/src/rust/components/chat_panel.rs +289 -0
  92. package/src/rust/components/column_selector/config_selector.rs +76 -7
  93. package/src/rust/components/column_selector.rs +7 -0
  94. package/src/rust/components/containers/dragdrop_list.rs +37 -0
  95. package/src/rust/components/form/code_editor.rs +14 -23
  96. package/src/rust/components/form/debug.rs +8 -5
  97. package/src/rust/components/form/mirrored_textarea.rs +165 -0
  98. package/src/rust/components/form/mod.rs +1 -0
  99. package/src/rust/components/mod.rs +2 -0
  100. package/src/rust/components/settings_panel.rs +80 -2
  101. package/src/rust/components/viewer/panels.rs +9 -14
  102. package/src/rust/components/viewer/render.rs +2 -0
  103. package/src/rust/components/window_editor.rs +75 -103
  104. package/src/rust/config/datetime_column_style/custom.rs +17 -0
  105. package/src/rust/config/datetime_column_style/simple.rs +10 -0
  106. package/src/rust/config/number_string_format.rs +33 -0
  107. package/src/rust/config/options.rs +18 -0
  108. package/src/rust/config/plugin_static_config.rs +120 -5
  109. package/src/rust/config/viewer_config.rs +220 -1
  110. package/src/rust/config/workspace_config.rs +7 -6
  111. package/src/rust/custom_elements/viewer.rs +209 -18
  112. package/src/rust/custom_events.rs +29 -0
  113. package/src/rust/lib.rs +13 -0
  114. package/src/rust/presentation.rs +8 -0
  115. package/src/rust/queries/get_viewer_config.rs +5 -1
  116. package/src/rust/session/column_defaults_update.rs +18 -0
  117. package/src/rust/session/metadata.rs +18 -20
  118. package/src/rust/session/replace_expression_update.rs +1 -0
  119. package/src/rust/tasks/create_panel.rs +18 -8
  120. package/src/rust/tasks/edit_window.rs +1 -1
  121. package/src/rust/tasks/restore_panel.rs +34 -17
  122. package/src/rust/tasks/sync_update_panels.rs +7 -23
  123. package/src/rust/workspace.rs +112 -20
  124. package/src/themes/intl/de.css +16 -0
  125. package/src/themes/intl/es.css +16 -0
  126. package/src/themes/intl/fr.css +16 -0
  127. package/src/themes/intl/ja.css +16 -0
  128. package/src/themes/intl/pt.css +16 -0
  129. package/src/themes/intl/zh.css +16 -0
  130. package/src/themes/intl.css +16 -0
  131. package/src/ts/column-format.ts +67 -46
  132. package/src/ts/perspective-viewer.ts +2 -0
  133. package/src/ts/providers.ts +96 -0
  134. package/src/ts/ts-rs/CompactDisplay.ts +3 -0
  135. package/src/ts/ts-rs/CurrencyCode.ts +3 -0
  136. package/src/ts/ts-rs/CurrencyDisplay.ts +3 -0
  137. package/src/ts/ts-rs/CurrencyNumberFormatStyle.ts +6 -0
  138. package/src/ts/ts-rs/CurrencySign.ts +3 -0
  139. package/src/ts/ts-rs/CustomDatetimeFormat.ts +3 -0
  140. package/src/ts/ts-rs/CustomDatetimeStyleConfig.ts +54 -0
  141. package/src/ts/ts-rs/CustomNumberFormatConfig.ts +25 -0
  142. package/src/ts/ts-rs/DatetimeColorMode.ts +3 -0
  143. package/src/ts/ts-rs/DatetimeFormatType.ts +8 -0
  144. package/src/ts/ts-rs/FormatMode.ts +3 -0
  145. package/src/ts/ts-rs/FormatUnit.ts +3 -0
  146. package/src/ts/ts-rs/Notation.ts +11 -0
  147. package/src/ts/ts-rs/NumberFormatStyle.ts +12 -0
  148. package/src/ts/ts-rs/PanelViewerConfig.ts +47 -1
  149. package/src/ts/ts-rs/PluginStaticConfig.ts +36 -1
  150. package/src/ts/ts-rs/RestoreOptions.ts +20 -0
  151. package/src/ts/ts-rs/RoundingMode.ts +3 -0
  152. package/src/ts/ts-rs/RoundingPriority.ts +3 -0
  153. package/src/ts/ts-rs/SignDisplay.ts +3 -0
  154. package/src/ts/ts-rs/SimpleDatetimeFormat.ts +3 -0
  155. package/src/ts/ts-rs/SimpleDatetimeStyleConfig.ts +25 -0
  156. package/src/ts/ts-rs/SplitRollupMode.ts +11 -0
  157. package/src/ts/ts-rs/StringColorMode.ts +3 -0
  158. package/src/ts/ts-rs/TrailingZeroDisplay.ts +3 -0
  159. package/src/ts/ts-rs/Unit.ts +3 -0
  160. package/src/ts/ts-rs/UnitDisplay.ts +3 -0
  161. package/src/ts/ts-rs/UnitNumberFormatStyle.ts +5 -0
  162. package/src/ts/ts-rs/UseGrouping.ts +3 -0
  163. package/src/ts/ts-rs/ViewerConfig.ts +47 -1
  164. package/src/ts/ts-rs/ViewerConfigInitial.ts +164 -0
  165. package/src/ts/ts-rs/ViewerConfigUpdate.ts +72 -2
  166. package/src/ts/ts-rs/WindowSpec.ts +3 -2
  167. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +7 -6
  168. package/dist/esm/ts-rs/WindowAggregate.d.ts +0 -1
  169. package/src/ts/ts-rs/WindowAggregate.ts +0 -3
  170. /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-02e461f3d7062b26}/inline0.js +0 -0
  171. /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-02e461f3d7062b26}/inline1.js +0 -0
  172. /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-02e461f3d7062b26}/inline2.js +0 -0
  173. /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-02e461f3d7062b26}/inline3.js +0 -0
  174. /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-02e461f3d7062b26}/inline4.js +0 -0
  175. /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-02e461f3d7062b26}/inline5.js +0 -0
@@ -0,0 +1,289 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ //! The chat sidebar tab (feature `llm-agent`) — a pure VIEW over
14
+ //! [`AgentSlot`]: all conversation state lives on the slot so this component
15
+ //! can unmount (tab switch, settings close) without losing the transcript or
16
+ //! interrupting a running turn.
17
+
18
+ use yew::prelude::*;
19
+
20
+ use crate::agent::{AgentSlot, ChatEntry, render_markdown};
21
+ use crate::components::form::mirrored_textarea::MirroredTextarea;
22
+
23
+ #[derive(Properties, PartialEq)]
24
+ struct ChatReasoningProps {
25
+ reasoning: String,
26
+ open: bool,
27
+ }
28
+
29
+ /// A reasoning-model's "thinking" text: plain-text collapsible block,
30
+ /// expanded while it is streaming (the only progress signal during a long
31
+ /// think) and collapsed once real content exists.
32
+ ///
33
+ /// The body is capped and scrolls (`viewer.css`), and follows its own
34
+ /// tail as deltas append — but only while the reader is AT that tail.
35
+ /// Scrolling up to read something is a deliberate act, so it wins until
36
+ /// the reader returns to the bottom, exactly like the transcript.
37
+ #[function_component]
38
+ fn ChatReasoning(props: &ChatReasoningProps) -> Html {
39
+ let body_ref = use_node_ref();
40
+ let scroll_pinned = use_mut_ref(|| true);
41
+ let seen_len = use_mut_ref(|| None::<usize>);
42
+ let onscroll = {
43
+ let body_ref = body_ref.clone();
44
+ let scroll_pinned = scroll_pinned.clone();
45
+ Callback::from(move |_: Event| {
46
+ if let Some(elem) = body_ref.cast::<web_sys::Element>() {
47
+ let gap = elem.scroll_height() - elem.scroll_top() - elem.client_height();
48
+ *scroll_pinned.borrow_mut() = gap < 24;
49
+ }
50
+ })
51
+ };
52
+
53
+ {
54
+ let body_ref = body_ref.clone();
55
+ let scroll_pinned = scroll_pinned.clone();
56
+ let seen_len = seen_len.clone();
57
+ let len = props.reasoning.len();
58
+ use_effect(move || {
59
+ let grew = seen_len.replace(Some(len)).is_some_and(|seen| len > seen);
60
+ if grew
61
+ && *scroll_pinned.borrow()
62
+ && let Some(elem) = body_ref.cast::<web_sys::Element>()
63
+ {
64
+ elem.set_scroll_top(elem.scroll_height());
65
+ }
66
+ });
67
+ }
68
+
69
+ html! {
70
+ <details class="chat-reasoning" open={props.open}>
71
+ <summary />
72
+ <div class="chat-reasoning-body scrollable" ref={body_ref} {onscroll}>
73
+ { &props.reasoning }
74
+ </div>
75
+ </details>
76
+ }
77
+ }
78
+
79
+ /// The prompt input, over the shared [`MirroredTextarea`] — so it grows
80
+ /// with its text instead of scrolling. This consumer wraps
81
+ /// (`white-space: pre-wrap` is the shared default), which confines growth
82
+ /// to the vertical axis; the expression editor's `pre` instead lets it
83
+ /// grow sideways too.
84
+ ///
85
+ /// Split from [`ChatPanel`] because the mirror re-renders per keystroke:
86
+ /// the transcript re-parses markdown for every message it renders, which
87
+ /// must not happen per character.
88
+ #[derive(Properties, PartialEq)]
89
+ struct ChatInputProps {
90
+ agent: AgentSlot,
91
+ busy: bool,
92
+ }
93
+
94
+ #[function_component]
95
+ fn ChatInput(props: &ChatInputProps) -> Html {
96
+ let input_ref = use_node_ref();
97
+
98
+ // Mirrors the textarea's value. The textarea is deliberately NOT
99
+ // value-bound — binding it fights the caret — so this is a shadow of
100
+ // the DOM value, written on `input` and cleared with it on submit.
101
+ let text = use_state_eq(String::new);
102
+ let submit = {
103
+ let agent = props.agent.clone();
104
+ let input_ref = input_ref.clone();
105
+ let text = text.clone();
106
+ Callback::from(move |()| {
107
+ if agent.is_busy() {
108
+ return;
109
+ }
110
+
111
+ if let Some(elem) = input_ref.cast::<web_sys::HtmlTextAreaElement>() {
112
+ let prompt = elem.value().trim().to_owned();
113
+ if prompt.is_empty() {
114
+ return;
115
+ }
116
+
117
+ elem.set_value("");
118
+ text.set(String::new());
119
+ let agent = agent.clone();
120
+ wasm_bindgen_futures::spawn_local(async move {
121
+ // Failures land in the transcript; nothing to propagate.
122
+ let _ = agent.run_prompt(prompt).await;
123
+ });
124
+ }
125
+ })
126
+ };
127
+
128
+ let onkeydown = {
129
+ let submit = submit.clone();
130
+ Callback::from(move |event: KeyboardEvent| {
131
+ if event.key() == "Enter" && !event.shift_key() {
132
+ event.prevent_default();
133
+ submit.emit(());
134
+ }
135
+ })
136
+ };
137
+
138
+ // `input` fires after the value settles and covers paste, cut and
139
+ // Shift+Enter newlines, none of which `keydown` sees correctly.
140
+ let oninput = {
141
+ let input_ref = input_ref.clone();
142
+ let text = text.clone();
143
+ Callback::from(move |_: InputEvent| {
144
+ if let Some(elem) = input_ref.cast::<web_sys::HtmlTextAreaElement>() {
145
+ text.set(elem.value());
146
+ }
147
+ })
148
+ };
149
+
150
+ let onsend = submit.reform(|_: MouseEvent| ());
151
+ let onstop = {
152
+ let agent = props.agent.clone();
153
+ Callback::from(move |_: MouseEvent| agent.stop())
154
+ };
155
+
156
+ html! {
157
+ <div id="chat_input_row">
158
+ <div id="chat_input_scroll" class="scrollable">
159
+ <MirroredTextarea
160
+ id="chat_input"
161
+ class="chat-input-box"
162
+ mirror={html! { (*text).clone() }}
163
+ is_empty={text.is_empty()}
164
+ textarea_ref={input_ref}
165
+ placeholder="Ask about your data\u{2026}"
166
+ disabled={props.busy}
167
+ {onkeydown}
168
+ {oninput}
169
+ />
170
+ </div>
171
+ if props.busy {
172
+ <button id="chat_stop_button" onclick={onstop} />
173
+ } else {
174
+ <button id="chat_send_button" onclick={onsend} />
175
+ }
176
+ </div>
177
+ }
178
+ }
179
+
180
+ #[derive(Properties, PartialEq)]
181
+ pub struct ChatPanelProps {
182
+ pub agent: AgentSlot,
183
+ }
184
+
185
+ #[function_component]
186
+ pub fn ChatPanel(props: &ChatPanelProps) -> Html {
187
+ let update = use_force_update();
188
+ {
189
+ let agent = props.agent.clone();
190
+ use_effect_with((), move |_| {
191
+ let sub = agent
192
+ .on_update
193
+ .add_notify_listener(&Callback::from(move |_| update.force_update()));
194
+
195
+ move || drop(sub)
196
+ });
197
+ }
198
+
199
+ // Autoscroll only while the user is pinned at the bottom — reading
200
+ // scrolled-back transcript must survive incoming streaming deltas.
201
+ // `onscroll` fires for programmatic scrolls too, so scrolling to the
202
+ // bottom re-pins consistently.
203
+ let log_ref = use_node_ref();
204
+ let scroll_pinned = use_mut_ref(|| true);
205
+ let onscroll = {
206
+ let log_ref = log_ref.clone();
207
+ let scroll_pinned = scroll_pinned.clone();
208
+ Callback::from(move |_: Event| {
209
+ if let Some(elem) = log_ref.cast::<web_sys::Element>() {
210
+ let gap = elem.scroll_height() - elem.scroll_top() - elem.client_height();
211
+ *scroll_pinned.borrow_mut() = gap < 30;
212
+ }
213
+ })
214
+ };
215
+
216
+ {
217
+ let log_ref = log_ref.clone();
218
+ let scroll_pinned = scroll_pinned.clone();
219
+ use_effect(move || {
220
+ if let Some(elem) = log_ref.cast::<web_sys::Element>()
221
+ && *scroll_pinned.borrow()
222
+ {
223
+ elem.set_scroll_top(elem.scroll_height());
224
+ }
225
+ });
226
+ }
227
+
228
+ let busy = props.agent.is_busy();
229
+ let entries = props
230
+ .agent
231
+ .transcript()
232
+ .into_iter()
233
+ .map(|entry| match entry {
234
+ ChatEntry::User(text) => html! {
235
+ <div class="chat-message chat-user">{ text.trim() }</div>
236
+ },
237
+ ChatEntry::Assistant { text, reasoning } => html! {
238
+ <div class="chat-message chat-assistant">
239
+ if let Some(reasoning) = reasoning { <ChatReasoning {reasoning} open=false /> }
240
+ { render_markdown(&text) }
241
+ </div>
242
+ },
243
+ ChatEntry::Tool { name, args, error } => {
244
+ let class = if error.is_some() {
245
+ "chat-tool-chip chat-tool-chip-error"
246
+ } else {
247
+ "chat-tool-chip"
248
+ };
249
+
250
+ let title = match &error {
251
+ Some(err) => format!("{err}\n\n{args}"),
252
+ None => args.clone(),
253
+ };
254
+
255
+ html! { <div {class} {title}>{ name }</div> }
256
+ },
257
+ ChatEntry::Error(text) => html! { <div class="chat-message chat-error">{ text }</div> },
258
+ })
259
+ .collect::<Html>();
260
+
261
+ // The in-flight turn's streaming tail: reasoning renders auto-OPEN
262
+ // while it is the only signal, collapsing once answer text starts;
263
+ // no deltas yet (or between tool rounds) shows the typing dots.
264
+ let pending = props.agent.pending();
265
+ let tail = match &pending {
266
+ Some((text, reasoning)) if !text.is_empty() || !reasoning.is_empty() => html! {
267
+ <div class="chat-message chat-assistant chat-streaming">
268
+ if !reasoning.is_empty() {
269
+ <ChatReasoning reasoning={reasoning.clone()} open={text.is_empty()} />
270
+ }
271
+ if !text.is_empty() {
272
+ { render_markdown(text) }
273
+ }
274
+ </div>
275
+ },
276
+ _ if busy => html! {
277
+ <div class="chat-message chat-assistant chat-pending"><span /><span /><span /></div>
278
+ },
279
+ _ => html! {},
280
+ };
281
+
282
+ html! {
283
+ <div id="chat_panel">
284
+ <div id="chat_log" class="scrollable" ref={log_ref} {onscroll}>{ entries }{ tail }</div>
285
+ <ChatInput agent={props.agent.clone()} {busy} />
286
+ <div id="chat_badge">{ props.agent.label().unwrap_or_default() }</div>
287
+ </div>
288
+ }
289
+ }
@@ -24,6 +24,7 @@ use crate::components::column_dropdown::{ColumnDropDownElement, ColumnDropDownPo
24
24
  use crate::components::containers::dragdrop_list::*;
25
25
  use crate::components::containers::select::{Select, SelectItem};
26
26
  use crate::components::filter_dropdown::{FilterDropDownElement, FilterDropDownPortal};
27
+ use crate::config::PluginStaticConfig;
27
28
  use crate::presentation::Presentation;
28
29
  use crate::renderer::*;
29
30
  use crate::session::drag_drop_update::*;
@@ -48,6 +49,14 @@ pub struct ConfigSelectorProps {
48
49
  /// Session metadata snapshot — threaded from `SessionProps`.
49
50
  pub metadata: SessionMetadataRc,
50
51
 
52
+ /// The ACTIVE plugin's declared contract, threaded as a VALUE prop.
53
+ /// Read from `renderer.metadata()` during render instead, the pivot
54
+ /// labels went stale: `renderer` is deliberately excluded from prop
55
+ /// equality, so switching Y Line back to Datagrid — same view config,
56
+ /// same named-slot count — re-rendered nothing and left the previous
57
+ /// plugin's role labels on screen until the panel was reopened.
58
+ pub plugin_static_config: Rc<PluginStaticConfig>,
59
+
51
60
  /// Selected theme name, threaded for PortalModal consumers.
52
61
  pub selected_theme: Option<String>,
53
62
 
@@ -63,6 +72,7 @@ impl PartialEq for ConfigSelectorProps {
63
72
  && self.drag_column == other.drag_column
64
73
  && self.metadata == other.metadata
65
74
  && self.selected_theme == other.selected_theme
75
+ && self.plugin_static_config == other.plugin_static_config
66
76
  }
67
77
  }
68
78
 
@@ -76,6 +86,7 @@ pub enum ConfigSelectorMsg {
76
86
  TransposePivots,
77
87
  New(DragTarget, InPlaceColumn),
78
88
  UpdateGroupRollupMode(GroupRollupMode),
89
+ UpdateSplitRollupMode(SplitRollupMode),
79
90
  }
80
91
 
81
92
  #[derive(Clone)]
@@ -156,6 +167,22 @@ impl Component for ConfigSelector {
156
167
 
157
168
  false
158
169
  },
170
+ ConfigSelectorMsg::UpdateSplitRollupMode(mode) => {
171
+ let config = ViewConfigUpdate {
172
+ split_rollup_mode: Some(mode),
173
+ ..ViewConfigUpdate::default()
174
+ };
175
+
176
+ {
177
+ let session = ctx.props().session.clone();
178
+ let renderer = ctx.props().renderer.clone();
179
+ if let Ok(task) = apply_and_render(&session, &renderer, config) {
180
+ spawn_owned("config-selector", task);
181
+ }
182
+ }
183
+
184
+ false
185
+ },
159
186
  ConfigSelectorMsg::Close(index, DragTarget::GroupBy) => {
160
187
  if ctx.props().view_config.group_rollup_mode == GroupRollupMode::Total {
161
188
  let requirements = ctx.props().renderer.metadata();
@@ -531,6 +558,18 @@ impl Component for ConfigSelector {
531
558
  ..
532
559
  } = ctx.props();
533
560
  let config = &ctx.props().view_config;
561
+
562
+ // What the ACTIVE plugin says these pivots draw — the same
563
+ // declaration the agent reads through `list_plugins`, so the
564
+ // label a user sees and the role a model is told cannot drift.
565
+ let plugin_roles = {
566
+ let plugin = &ctx.props().plugin_static_config;
567
+ (
568
+ plugin.group_by_role.clone().map(AttrValue::from),
569
+ plugin.split_by_role.clone().map(AttrValue::from),
570
+ )
571
+ };
572
+
534
573
  let transpose = ctx.link().callback(|_| ConfigSelectorMsg::TransposePivots);
535
574
  let column_dropdown = self.column_dropdown.clone();
536
575
  let mut class = classes!();
@@ -562,6 +601,17 @@ impl Component for ConfigSelector {
562
601
 
563
602
  let group_rollups = requirements.get_group_rollups(&rollup_features);
564
603
 
604
+ let on_split_rollup_mode = ctx
605
+ .link()
606
+ .callback(ConfigSelectorMsg::UpdateSplitRollupMode);
607
+
608
+ let split_rollup_features = metadata
609
+ .get_features()
610
+ .map(|x| x.get_split_rollup_modes())
611
+ .unwrap();
612
+
613
+ let split_rollups = requirements.get_split_rollups(&split_rollup_features);
614
+
565
615
  html! {
566
616
  <>
567
617
  <div slot="top_panel" id="top_panel" {class} ondragend={dragend}>
@@ -593,6 +643,7 @@ impl Component for ConfigSelector {
593
643
  if features.group_by {
594
644
  <GroupBySelector
595
645
  name="group_by"
646
+ role_label={plugin_roles.0.clone()}
596
647
  disabled={config.group_rollup_mode == GroupRollupMode::Total}
597
648
  parent={ctx.link().clone()}
598
649
  column_dropdown={column_dropdown.clone()}
@@ -615,18 +666,36 @@ impl Component for ConfigSelector {
615
666
  </GroupBySelector>
616
667
  }
617
668
  if features.split_by {
618
- if !config.split_by.is_empty() {
669
+ if !config.split_by.is_empty() || split_rollups.len() > 1 {
619
670
  <div class="pivot_controls">
620
- <span
621
- id="transpose_button"
622
- class="rrow centered"
623
- title="Transpose Pivots"
624
- onmousedown={transpose}
625
- />
671
+ if split_rollups.len() > 1 {
672
+ <Select<SplitRollupMode>
673
+ id="split_rollup_mode_selector"
674
+ wrapper_class="split_rollup_wrapper"
675
+ is_autosize=true
676
+ values={Rc::new(
677
+ split_rollups
678
+ .iter()
679
+ .map(|x| SelectItem::Option(*x))
680
+ .collect(),
681
+ )}
682
+ selected={config.split_rollup_mode}
683
+ on_select={on_split_rollup_mode}
684
+ />
685
+ }
686
+ if !config.split_by.is_empty() {
687
+ <span
688
+ id="transpose_button"
689
+ class="rrow centered"
690
+ title="Transpose Pivots"
691
+ onmousedown={transpose}
692
+ />
693
+ }
626
694
  </div>
627
695
  }
628
696
  <SplitBySelector
629
697
  name="split_by"
698
+ role_label={plugin_roles.1.clone()}
630
699
  parent={ctx.link().clone()}
631
700
  column_dropdown={column_dropdown.clone()}
632
701
  exclude={config.split_by.iter().cloned().collect::<HashSet<_>>()}
@@ -42,6 +42,7 @@ use super::containers::scroll_panel::*;
42
42
  use super::containers::split_panel::{Orientation, SplitPanel};
43
43
  use crate::components::column_dropdown::{ColumnDropDownElement, ColumnDropDownPortal};
44
44
  use crate::components::containers::scroll_panel_item::ScrollPanelItem;
45
+ use crate::config::PluginStaticConfig;
45
46
  use crate::presentation::{ColumnLocator, DragDropContainer, Presentation};
46
47
  use crate::queries::{ActiveColumnState, ActiveColumnStateData, ColumnsIteratorSet};
47
48
  use crate::renderer::*;
@@ -61,6 +62,10 @@ pub struct ColumnSelectorProps {
61
62
  /// Value props threaded from root's `SessionProps` / `RendererProps`.
62
63
  pub has_table: Option<TableLoadState>,
63
64
  pub named_column_count: usize,
65
+
66
+ /// The ACTIVE plugin's declared contract — see the identically named
67
+ /// prop on `SettingsPanelProps`.
68
+ pub plugin_static_config: Rc<PluginStaticConfig>,
64
69
  pub view_config: PtrEqRc<ViewConfig>,
65
70
  pub drag_column: Option<String>,
66
71
 
@@ -103,6 +108,7 @@ impl PartialEq for ColumnSelectorProps {
103
108
  self.selected_column == rhs.selected_column
104
109
  && self.has_table == rhs.has_table
105
110
  && self.named_column_count == rhs.named_column_count
111
+ && self.plugin_static_config == rhs.plugin_static_config
106
112
  && self.view_config == rhs.view_config
107
113
  && self.drag_column == rhs.drag_column
108
114
  && self.metadata == rhs.metadata
@@ -346,6 +352,7 @@ impl Component for ColumnSelector {
346
352
  drag_column={ctx.props().drag_column.clone()}
347
353
  metadata={metadata.clone()}
348
354
  selected_theme={ctx.props().selected_theme.clone()}
355
+ plugin_static_config={ctx.props().plugin_static_config.clone()}
349
356
  {presentation}
350
357
  {renderer}
351
358
  {session}
@@ -60,6 +60,16 @@ where
60
60
  #[prop_or_default]
61
61
  pub single_slot: bool,
62
62
 
63
+ /// The plugin-declared visual role this slot fills, e.g. `"X Axis"`
64
+ /// for a `Y Line`'s `group_by` (see `PluginStaticConfig`). Rendered
65
+ /// as the slot's label THROUGH the intl indirection: the role is a
66
+ /// key, not display text, so `--psp-label--role--x-axis--content`
67
+ /// supplies the words and a language variant can override them. A
68
+ /// role with no such label falls back to the declared English, and
69
+ /// no role at all falls back to the slot's own generic label.
70
+ #[prop_or_default]
71
+ pub role_label: Option<AttrValue>,
72
+
63
73
  /// The in-flight drag is INVALID for this list (a parent-defined rule,
64
74
  /// e.g. the window editor's Table-columns-only slots): the dragover
65
75
  /// preview is suppressed and the invalid-X overlay renders instead,
@@ -83,6 +93,7 @@ where
83
93
  && self.disabled == other.disabled
84
94
  && self.single_slot == other.single_slot
85
95
  && self.is_invalid == other.is_invalid
96
+ && self.role_label == other.role_label
86
97
  }
87
98
  }
88
99
 
@@ -390,10 +401,36 @@ where
390
401
  let class = classes!("rrow");
391
402
  let is_enabled = true;
392
403
 
404
+ // The role is data from the plugin, so the var NAME is built
405
+ // here while the var VALUE stays in the stylesheets - custom
406
+ // properties inherit, so the label's `:before` picks it up.
407
+ let role_style = ctx.props().role_label.as_ref().map(|role| {
408
+ let slug = role
409
+ .chars()
410
+ .map(|x| {
411
+ if x.is_ascii_alphanumeric() {
412
+ x.to_ascii_lowercase()
413
+ } else {
414
+ '-'
415
+ }
416
+ })
417
+ .collect::<String>();
418
+
419
+ // The second var is a PRESENCE FLAG: CSS cannot ask whether a
420
+ // custom property is set, so the slot's stylesheet reads it as
421
+ // the secondary label's `display` and gets `none` by fallback
422
+ // when no role was declared.
423
+ format!(
424
+ "--psp-label--pivot--content: var(--psp-label--role--{slug}--content, \
425
+ \"{role}\"); --psp-label--pivot-secondary--display: inline-block"
426
+ )
427
+ });
428
+
393
429
  html! {
394
430
  <div ref={&self.elem} {class}>
395
431
  <div
396
432
  id={ctx.props().name}
433
+ style={role_style}
397
434
  ondragover={is_enabled.then_some(dragover)}
398
435
  ondragenter={is_enabled.then_some(drag_container.dragenter)}
399
436
  ondragleave={is_enabled.then_some(drag_container.dragleave)}
@@ -18,6 +18,7 @@ use web_sys::*;
18
18
  use yew::prelude::*;
19
19
 
20
20
  use crate::components::form::highlight::highlight;
21
+ use crate::components::form::mirrored_textarea::MirroredTextarea;
21
22
  use crate::components::function_dropdown::{FunctionDropDownElement, FunctionDropDownPortal};
22
23
  use crate::exprtk::{Cursor, tokenize};
23
24
  use crate::utils::*;
@@ -174,30 +175,20 @@ pub fn code_editor(props: &CodeEditorProps) -> Html {
174
175
  <>
175
176
  <div id="editor" {class}>
176
177
  <div id="line_numbers" ref={lineno_ref}>{ line_numbers }</div>
177
- <div id="editor-inner" {class}>
178
- <textarea
179
- {disabled}
180
- id="textarea_editable"
181
- class="scrollable"
182
- ref={textarea_ref}
183
- spellcheck="false"
184
- {oninput}
185
- {onscroll}
186
- {onkeydown}
187
- />
178
+ <MirroredTextarea
179
+ id="textarea_editable"
180
+ mirror_id="content"
181
+ class={classes!("editor-inner", class)}
182
+ mirror={terms}
183
+ {textarea_ref}
184
+ mirror_ref={content_ref}
185
+ {disabled}
186
+ {oninput}
187
+ {onscroll}
188
+ {onkeydown}
189
+ >
188
190
  <div id="editor-height-sizer" />
189
- <pre id="content" ref={content_ref}>
190
- { terms }
191
- { {
192
- // A linebreak which pushs a textarea into scroll overflow
193
- // may not necessarily do so in the `<pre>`, because there is
194
- // no cursor when the last line has no content, so add
195
- // some space here to make sure overlfow is in sync
196
- // with the text area.
197
- " "
198
- } }
199
- </pre>
200
- </div>
191
+ </MirroredTextarea>
201
192
  </div>
202
193
  <FunctionDropDownPortal
203
194
  element={(*portal_dropdown).clone()}
@@ -23,12 +23,14 @@ use crate::presentation::*;
23
23
  use crate::renderer::*;
24
24
  use crate::session::*;
25
25
  use crate::utils::*;
26
+ use crate::workspace::Workspace;
26
27
 
27
28
  #[derive(Clone, PartialEq, Properties)]
28
29
  pub struct DebugPanelProps {
29
30
  pub presentation: Presentation,
30
31
  pub renderer: Renderer,
31
32
  pub session: Session,
33
+ pub workspace: Workspace,
32
34
 
33
35
  /// Trap-door width pinned by the parent `SettingsPanel` so switching
34
36
  /// tabs doesn't shrink the panel. Threaded into the hidden sizer
@@ -236,15 +238,16 @@ impl DebugPanelProps {
236
238
  ApiFuture::spawn(async move {
237
239
  match serde_json::from_str(&text) {
238
240
  Ok(config) => {
239
- match crate::tasks::restore_and_render(
241
+ let active =
242
+ props.workspace.active_renderer().as_ref() == Some(&props.renderer);
243
+ match crate::tasks::restore_panel(
240
244
  &props.session,
241
245
  &props.renderer,
242
246
  &props.presentation,
243
- // A user-pasted config apply — an explicit request,
244
- // same affordance as the public `restore()`.
245
- crate::tasks::RunOrigin::Public,
247
+ &props.workspace,
248
+ crate::tasks::RestoreMode::Existing { active },
246
249
  config,
247
- async { Ok(()) },
250
+ crate::tasks::RestoreErrors::Suppress,
248
251
  )
249
252
  .await
250
253
  {