@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
@@ -261,7 +261,36 @@ pub fn wire_element_events(
261
261
  }
262
262
  });
263
263
 
264
+ let layout_sub = workspace.layout_changed().add_listener({
265
+ clone!(elem);
266
+ move |panels: Vec<crate::workspace::PanelId>| {
267
+ let ids = panels
268
+ .iter()
269
+ .map(|id| JsValue::from_str(id.as_str()))
270
+ .collect::<js_sys::Array>();
271
+
272
+ let detail = js_sys::Object::new();
273
+ let _ = js_sys::Reflect::set(&detail, &JsValue::from_str("panels"), &ids);
274
+ dispatch_event(&elem, "layout-update", JsValue::from(detail)).unwrap();
275
+ }
276
+ });
277
+
278
+ let active_panel_sub = workspace.active_changed().add_listener({
279
+ clone!(elem);
280
+ move |active: Option<crate::workspace::PanelId>| {
281
+ let panel = active
282
+ .map(|id| JsValue::from_str(id.as_str()))
283
+ .unwrap_or(JsValue::NULL);
284
+
285
+ let detail = js_sys::Object::new();
286
+ let _ = js_sys::Reflect::set(&detail, &JsValue::from_str("panel"), &panel);
287
+ dispatch_event(&elem, "active-panel-update", JsValue::from(detail)).unwrap();
288
+ }
289
+ });
290
+
264
291
  vec![
292
+ layout_sub,
293
+ active_panel_sub,
265
294
  theme_sub,
266
295
  before_settings_sub,
267
296
  settings_sub,
package/src/rust/lib.rs CHANGED
@@ -31,6 +31,8 @@
31
31
  // `SESSION_CONFIG_COHERENCE_PLAN.md`) — deny, don't warn.
32
32
  #![deny(clippy::await_holding_refcell_ref)]
33
33
 
34
+ #[cfg(feature = "llm-agent")]
35
+ mod agent;
34
36
  pub mod components;
35
37
  pub mod config;
36
38
  pub mod custom_elements;
@@ -86,22 +88,33 @@ import type {
86
88
 
87
89
  export type * from "../../src/ts/ts-rs/ViewerConfig.d.ts";
88
90
  export type * from "../../src/ts/ts-rs/ViewerConfigUpdate.d.ts";
91
+ export type * from "../../src/ts/ts-rs/ViewerConfigInitial.d.ts";
89
92
  export type * from "../../src/ts/ts-rs/PluginStaticConfig.d.ts";
90
93
  export type * from "../../src/ts/ts-rs/WorkspaceConfig.d.ts";
91
94
  export type * from "../../src/ts/ts-rs/WorkspaceConfigUpdate.d.ts";
92
95
  export type * from "../../src/ts/ts-rs/ExportMethod.d.ts";
93
96
  export type * from "../../src/ts/ts-rs/PanelOptions.d.ts";
97
+ export type * from "../../src/ts/ts-rs/RestoreOptions.d.ts";
94
98
  export type * from "../../src/ts/ts-rs/ClientOptions.d.ts";
95
99
  export type * from "../../src/ts/ts-rs/ExportOptions.d.ts";
96
100
  export type * from "../../src/ts/ts-rs/GetTableOptions.d.ts";
97
101
  export type * from "../../src/ts/ts-rs/GetClientOptions.d.ts";
102
+ export type * from "../../src/ts/ts-rs/CustomNumberFormatConfig.d.ts";
103
+ export type * from "../../src/ts/ts-rs/NumberFormatStyle.d.ts";
104
+ export type * from "../../src/ts/ts-rs/Notation.d.ts";
105
+ export type * from "../../src/ts/ts-rs/DatetimeFormatType.d.ts";
106
+ export type * from "../../src/ts/ts-rs/StringColorMode.d.ts";
107
+ export type * from "../../src/ts/ts-rs/DatetimeColorMode.d.ts";
108
+ export type * from "../../src/ts/ts-rs/FormatMode.d.ts";
98
109
  import type {GetTableOptions} from "../../src/ts/ts-rs/GetTableOptions.d.ts";
99
110
  import type {PanelOptions} from "../../src/ts/ts-rs/PanelOptions.d.ts";
111
+ import type {RestoreOptions} from "../../src/ts/ts-rs/RestoreOptions.d.ts";
100
112
  import type {ExportOptions} from "../../src/ts/ts-rs/ExportOptions.d.ts";
101
113
  import type {GetClientOptions} from "../../src/ts/ts-rs/GetClientOptions.d.ts";
102
114
  import type {ClientOptions} from "../../src/ts/ts-rs/ClientOptions.d.ts";
103
115
  import type {ViewerConfig} from "../../src/ts/ts-rs/ViewerConfig.d.ts";
104
116
  import type {ViewerConfigUpdate} from "../../src/ts/ts-rs/ViewerConfigUpdate.d.ts";
117
+ import type {ViewerConfigInitial} from "../../src/ts/ts-rs/ViewerConfigInitial.d.ts";
105
118
  import type {WorkspaceConfig} from "../../src/ts/ts-rs/WorkspaceConfig.d.ts";
106
119
  import type {WorkspaceConfigUpdate} from "../../src/ts/ts-rs/WorkspaceConfigUpdate.d.ts";
107
120
  "#;
@@ -63,6 +63,12 @@ impl DragState {
63
63
  pub struct PresentationHandle {
64
64
  viewer_elem: HtmlElement,
65
65
 
66
+ /// The embedded LLM agent's shared model (runtime + chat transcript) —
67
+ /// carried here so the settings sidebar can render the chat tab without
68
+ /// threading a new prop chain from the element.
69
+ #[cfg(feature = "llm-agent")]
70
+ pub agent: crate::agent::AgentSlot,
71
+
66
72
  /// The available themes as detected in the browser environment or set
67
73
  /// explicitly when CORS prevents detection — a MEMO of a document
68
74
  /// external, not component state. `None` until first parsed (detection
@@ -148,6 +154,8 @@ impl Presentation {
148
154
  pub fn new(elem: &HtmlElement) -> Self {
149
155
  let theme = Self(Rc::new(PresentationHandle {
150
156
  viewer_elem: elem.clone(),
157
+ #[cfg(feature = "llm-agent")]
158
+ agent: Default::default(),
151
159
  themes: Default::default(),
152
160
  theme_init: Default::default(),
153
161
  is_workspace: Default::default(),
@@ -51,10 +51,14 @@ pub async fn get_viewer_config(
51
51
  None => presentation.get_selected_theme_name().await,
52
52
  };
53
53
  let title = session.get_title();
54
+ // Placed ⇒ bound (or binding in flight): creation requires a `table`
55
+ // by type, so a panel with neither a bound nor pending table is an
56
+ // invariant violation, not a serializable state.
54
57
  let table = session
55
58
  .get_table()
56
59
  .map(|x| x.get_name().to_owned())
57
- .or_else(|| session.pending_table());
60
+ .or_else(|| session.pending_table())
61
+ .ok_or_else(|| ApiError::from("Panel has no `table`"))?;
58
62
  let columns_config = renderer.all_columns_configs();
59
63
  Ok(ViewerConfig {
60
64
  settings,
@@ -52,6 +52,24 @@ pub impl ViewConfigUpdate {
52
52
  self.group_rollup_mode
53
53
  );
54
54
  }
55
+
56
+ let split_rollup_features = metadata
57
+ .get_features()
58
+ .map(|x| x.get_split_rollup_modes())
59
+ .unwrap_or_default();
60
+
61
+ let split_rollups = config_static.get_split_rollups(&split_rollup_features);
62
+ if !split_rollups.contains(
63
+ self.split_rollup_mode
64
+ .as_ref()
65
+ .unwrap_or(&SplitRollupMode::Flat),
66
+ ) {
67
+ self.split_rollup_mode = split_rollups.first().cloned();
68
+ tracing::debug!(
69
+ "Setting plugin-advised split rollup mode {:?}",
70
+ self.split_rollup_mode
71
+ );
72
+ }
55
73
  }
56
74
 
57
75
  /// Appends additional columns to the `columns` field of this
@@ -98,34 +98,20 @@ impl SessionMetadata {
98
98
 
99
99
  /// Records the pre-aggregation types of the config's window columns, so
100
100
  /// `get_column_table_type` resolves them like any other derived column.
101
- /// Types mirror the engine's `t_window_engine::resolve_dtype` rules from
102
- /// the window's op and its source's table type - independent of any
103
- /// aggregation the view may also apply.
104
101
  pub(super) fn update_windows(
105
102
  &mut self,
106
103
  windows: &perspective_client::config::Windows,
107
104
  ) -> ApiResult<()> {
108
- use perspective_client::config::WindowAggregate;
109
105
  let window_schema = windows
110
106
  .iter()
111
107
  .filter_map(|(name, w)| {
112
108
  let source = self.get_column_table_type(&w.column)?;
113
- let dtype = match w.aggregate {
114
- WindowAggregate::Sum
115
- | WindowAggregate::Avg
116
- | WindowAggregate::Stddev
117
- | WindowAggregate::Var
118
- | WindowAggregate::Rate
119
- | WindowAggregate::Ema
120
- | WindowAggregate::Diff => ColumnType::Float,
121
- WindowAggregate::Count => ColumnType::Integer,
122
- WindowAggregate::Min
123
- | WindowAggregate::Max
124
- | WindowAggregate::First
125
- | WindowAggregate::Last
126
- | WindowAggregate::Lag
127
- | WindowAggregate::Lead => source,
128
- };
109
+ let dtype = self
110
+ .get_window_aggregate(source, &w.aggregate)
111
+ .and_then(|spec| spec.result_type)
112
+ .and_then(|ty| ColumnType::try_from(ty).ok())
113
+ .unwrap_or(source);
114
+
129
115
  Some((name.clone(), dtype))
130
116
  })
131
117
  .collect();
@@ -133,6 +119,18 @@ impl SessionMetadata {
133
119
  Ok(())
134
120
  }
135
121
 
122
+ /// The declaration for one window aggregate over a `source` column type.
123
+ pub fn get_window_aggregate(
124
+ &self,
125
+ source: ColumnType,
126
+ name: &str,
127
+ ) -> Option<perspective_client::proto::WindowAggregateArgs> {
128
+ self.get_features()?
129
+ .get_window_aggregates(source)
130
+ .into_iter()
131
+ .find(|x| x.name == name)
132
+ }
133
+
136
134
  /// Whether `view_schema` has been populated by a prior successful
137
135
  /// `create_view`. Used by `Session::create_view` to force a full
138
136
  /// build on first run even when `is_clean` happens to be true, so
@@ -126,6 +126,7 @@ pub impl ViewConfig {
126
126
  filter_op: None,
127
127
  group_by_depth: None,
128
128
  group_rollup_mode: None,
129
+ split_rollup_mode: None,
129
130
  windows: None,
130
131
  }
131
132
  }
@@ -69,13 +69,23 @@ fn wire_panel_render_sub(session: &Session, renderer: &Renderer) -> Subscription
69
69
  })
70
70
  }
71
71
 
72
- /// Create a new independent panel (own `Session` + `Renderer` + id) from a
73
- /// `ViewerConfigUpdate`, mount and draw it, and return its id. Shared by
74
- /// `addPanel`, whole-element `restore`, and `restorePanel`'s create-if-missing
75
- /// path. `id` is the panel's id — provided when restoring into a specific named
76
- /// slot, or `None` to generate a fresh one. `settings`/`theme` are stripped
77
- /// (element-level, not per-panel) and `client` — or the element's default
78
- /// client when `None` — is bound so the config's `table` resolves against it.
72
+ /// Create a new independent panel (own `Session` + `Renderer` + id) and
73
+ /// restore `update` into it. Shared by `addPanel`, `restore`'s
74
+ /// create-if-missing upsert, and `restorePanel`. `id` is the panel's id —
75
+ /// provided when restoring into a specific named slot, or `None` to
76
+ /// generate a fresh one. `theme` is stripped (element-level, not
77
+ /// per-panel) and `client` — or the element's default client when `None` —
78
+ /// is bound so the config's `table` resolves against it.
79
+ ///
80
+ /// A `table` is OPTIONAL here: an update with none creates a deferred
81
+ /// panel that a later `load()` binds, which is the pre-existing
82
+ /// `restore`-then-`load` contract and the same state the reserved
83
+ /// `load()` path produces. The routes where a table-less panel WOULD be
84
+ /// permanently blank require one in their own argument types instead —
85
+ /// `addPanel`'s [`ViewerConfigInitial`], `WorkspaceConfigUpdate`'s
86
+ /// `panels` entries, and the agent's `add_panel` — so the guarantee sits
87
+ /// at the boundary that owns it rather than here, which would sweep
88
+ /// `restore`'s patch in with them.
79
89
  pub(crate) async fn create_panel(
80
90
  elem: &HtmlElement,
81
91
  presentation: &Presentation,
@@ -128,9 +138,9 @@ pub(crate) async fn create_panel(
128
138
  &renderer,
129
139
  presentation,
130
140
  workspace,
131
- None,
132
141
  RestoreMode::Fresh,
133
142
  update,
143
+ crate::tasks::RestoreErrors::Publish,
134
144
  )
135
145
  .await;
136
146
 
@@ -93,7 +93,7 @@ pub fn update_window(
93
93
  config
94
94
  .columns
95
95
  .iter()
96
- .map(|col| col.as_ref().map(&rename))
96
+ .map(|col| col.as_ref().map(rename))
97
97
  .collect(),
98
98
  );
99
99
  update.group_by = Some(config.group_by.iter().map(&rename).collect());
@@ -10,15 +10,12 @@
10
10
  // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
- use futures::channel::oneshot::channel;
14
13
  use perspective_client::clone;
15
14
  use perspective_client::utils::PerspectiveResultExt;
16
15
 
17
- use crate::components::viewer::{PerspectiveViewer, PerspectiveViewerMsg};
18
16
  use crate::config::*;
19
17
  use crate::presentation::Presentation;
20
18
  use crate::renderer::Renderer;
21
- use crate::root::Root;
22
19
  use crate::session::{ResetOptions, Session};
23
20
  use crate::tasks::*;
24
21
  use crate::workspace::Workspace;
@@ -31,6 +28,20 @@ pub(crate) enum RestoreMode {
31
28
  Fresh,
32
29
  }
33
30
 
31
+ /// Where a failed restore's error goes. `Publish` (every user-facing path)
32
+ /// commits it to the session — the visible error UI and errored session
33
+ /// state. `Suppress` (the agent's `set_view_config` tool) returns it ONLY
34
+ /// to the caller: the model receives the error as a tool result and
35
+ /// self-corrects, so the transient failure is noise to the human user —
36
+ /// and the errored session state would otherwise BLOCK the corrected
37
+ /// retry, since error recovery below requires a `table` field the model's
38
+ /// patches don't carry.
39
+ #[derive(Clone, Copy)]
40
+ pub(crate) enum RestoreErrors {
41
+ Publish,
42
+ Suppress,
43
+ }
44
+
34
45
  pub(crate) async fn bind_table_task(
35
46
  session: &Session,
36
47
  workspace: &Workspace,
@@ -57,9 +68,9 @@ pub(crate) async fn restore_panel(
57
68
  renderer: &Renderer,
58
69
  presentation: &Presentation,
59
70
  workspace: &Workspace,
60
- root: Option<&Root<PerspectiveViewer>>,
61
71
  mode: RestoreMode,
62
72
  mut update: ViewerConfigUpdate,
73
+ errors: RestoreErrors,
63
74
  ) -> ApiResult<()> {
64
75
  let active = matches!(mode, RestoreMode::Existing { active: true });
65
76
  let fresh = matches!(mode, RestoreMode::Fresh);
@@ -77,17 +88,17 @@ pub(crate) async fn restore_panel(
77
88
  tracing::info!("Restoring {update}");
78
89
  }
79
90
 
80
- let (sender, receiver) = channel::<()>();
81
- match (active, root) {
82
- (true, Some(root)) => {
83
- root.borrow().as_ref().into_apierror()?.send_message(
84
- PerspectiveViewerMsg::ToggleSettingsComplete(update.settings.clone(), sender),
85
- );
86
- },
87
- _ => {
88
- let _ = sender.send(());
89
- },
90
- }
91
+ // NOTE: `update.settings` is deliberately NOT applied here. It is
92
+ // element-level chrome rather than panel state, so `restore()` — the
93
+ // only caller that can carry it — applies it before dispatching, and
94
+ // this pipeline stays per-panel. Applying it here reached only the
95
+ // `Existing { active: true }` mode, which is why a freshly created
96
+ // panel silently ignored it.
97
+
98
+ // Under `Suppress` the restore is TRANSACTIONAL, so snapshot the config
99
+ // it is about to overwrite — see the failure tail below for why.
100
+ let rollback =
101
+ matches!(errors, RestoreErrors::Suppress).then(|| session.get_view_config().clone());
91
102
 
92
103
  let table_changed = !fresh
93
104
  && matches!(&update.table, OptionalUpdate::Update(name)
@@ -122,7 +133,6 @@ pub(crate) async fn restore_panel(
122
133
  bind_table_task(&session, &workspace, name).await?;
123
134
  }
124
135
 
125
- receiver.await.unwrap_or_log();
126
136
  Ok(())
127
137
  }
128
138
  },
@@ -130,7 +140,14 @@ pub(crate) async fn restore_panel(
130
140
  .await;
131
141
 
132
142
  if let Err(e) = &result {
133
- session.set_error(false, e.clone()).await?;
143
+ match errors {
144
+ RestoreErrors::Publish => session.set_error(false, e.clone()).await?,
145
+ RestoreErrors::Suppress => {
146
+ if let Some(config) = rollback {
147
+ session.commit_view_config(config.into()).unwrap_or_log();
148
+ }
149
+ },
150
+ }
134
151
  }
135
152
 
136
153
  result?;
@@ -55,20 +55,19 @@ pub fn sync_update_panels(
55
55
  let mut fallback_fresh: Option<String> = None;
56
56
  let mut active_fresh: Option<String> = None;
57
57
  let mut contents = Vec::new();
58
+ // `panels` entries are `ViewerConfigInitial`s — `table` required by
59
+ // TYPE (a placed panel without a binding would be permanently blank),
60
+ // and no per-panel `settings` exists (element-level; carried by the
61
+ // top-level `active` field). An EMPTY `panels` map restores to the
62
+ // zero-panel empty stage — the former table-less fallback panel was
63
+ // exactly the blank-panel state this type exists to preclude.
58
64
  for (saved_id, config) in panels {
59
- if !matches!(config.settings, OptionalUpdate::Missing) {
60
- #[rustfmt::skip]
61
- tracing::warn!(
62
- "`settings` on panel \"{saved_id}\" is ignored in a whole-element config; use the top-level `active` field"
63
- );
64
- }
65
-
66
65
  let (fresh, session, renderer, config) = create_panel_model(
67
66
  &this.elem,
68
67
  &this.presentation,
69
68
  &this.workspace,
70
69
  None,
71
- config,
70
+ config.into(),
72
71
  None,
73
72
  Placement::Placed,
74
73
  );
@@ -82,21 +81,6 @@ pub fn sync_update_panels(
82
81
  contents.push((fresh, session, renderer, config));
83
82
  }
84
83
 
85
- if contents.is_empty() {
86
- let (fresh, session, renderer, config) = create_panel_model(
87
- &this.elem,
88
- &this.presentation,
89
- &this.workspace,
90
- None,
91
- ViewerConfigUpdate::default(),
92
- None,
93
- Placement::Placed,
94
- );
95
-
96
- fallback_fresh = Some(fresh.as_str().to_owned());
97
- contents.push((fresh, session, renderer, config));
98
- }
99
-
100
84
  let mut eject_tasks = Vec::new();
101
85
  if let Some(panel) = this.workspace.take_reserved() {
102
86
  eject_tasks.push(eject_panel(panel));
@@ -21,7 +21,7 @@ use perspective_client::config::Filter;
21
21
 
22
22
  use crate::renderer::Renderer;
23
23
  use crate::session::Session;
24
- use crate::utils::{EffectLedger, PubSub, Subscription};
24
+ use crate::utils::{EffectLedger, PubSub, Subscription, spawn_owned};
25
25
 
26
26
  /// A unique identifier for a [`Panel`] within a [`Workspace`].
27
27
  #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -243,6 +243,22 @@ struct WorkspaceData {
243
243
  /// In-flight effects (public mutators + scheduled internal flows),
244
244
  /// drained by `flush()` — see [`EffectLedger`].
245
245
  effects: EffectLedger,
246
+
247
+ /// The PLACED panel set changed since the last emit. Mutation sites set
248
+ /// this and emit NOTHING; the coalescing flush task owns delivery (see
249
+ /// [`Workspace::schedule_layout_flush`]).
250
+ layout_dirty: bool,
251
+ layout_changed: Rc<PubSub<Vec<PanelId>>>,
252
+
253
+ /// The active panel changed since the last emit. A separate channel from
254
+ /// `layout_dirty`: "which panel is selected" and "which panels exist" are
255
+ /// distinct facts, and one event may not mean both.
256
+ active_dirty: bool,
257
+ active_changed: Rc<PubSub<Option<PanelId>>>,
258
+
259
+ /// A flush task is already queued — the flag that makes N mutations
260
+ /// within one operation schedule ONE task rather than N.
261
+ flush_scheduled: bool,
246
262
  }
247
263
 
248
264
  impl Default for Workspace {
@@ -269,6 +285,11 @@ impl Workspace {
269
285
  staged_changed: Rc::new(PubSub::default()),
270
286
  reserved: None,
271
287
  effects: EffectLedger::default(),
288
+ layout_dirty: false,
289
+ layout_changed: Rc::new(PubSub::default()),
290
+ active_dirty: false,
291
+ active_changed: Rc::new(PubSub::default()),
292
+ flush_scheduled: false,
272
293
  })))
273
294
  }
274
295
 
@@ -277,6 +298,62 @@ impl Workspace {
277
298
  self.0.borrow().effects.clone()
278
299
  }
279
300
 
301
+ pub fn layout_changed(&self) -> Rc<PubSub<Vec<PanelId>>> {
302
+ self.0.borrow().layout_changed.clone()
303
+ }
304
+
305
+ pub fn active_changed(&self) -> Rc<PubSub<Option<PanelId>>> {
306
+ self.0.borrow().active_changed.clone()
307
+ }
308
+
309
+ /// Queue the coalescing layout-event flush, if anything is dirty and no
310
+ /// flush is already pending.
311
+ fn schedule_layout_flush(&self) {
312
+ let schedule = {
313
+ let mut data = self.0.borrow_mut();
314
+ let dirty = data.layout_dirty || data.active_dirty;
315
+ let queued = data.flush_scheduled;
316
+ data.flush_scheduled |= dirty;
317
+ dirty && !queued
318
+ };
319
+
320
+ if !schedule {
321
+ return;
322
+ }
323
+
324
+ let effects = self.effects();
325
+ let this = self.clone();
326
+ spawn_owned("workspace_layout_flush", async move {
327
+ effects.settle().await;
328
+ this.flush_layout_events();
329
+ Ok(())
330
+ });
331
+ }
332
+
333
+ /// Emit whatever is dirty, outside any borrow.
334
+ fn flush_layout_events(&self) {
335
+ let (layout, active, panels, active_id, layout_pubsub, active_pubsub) = {
336
+ let mut data = self.0.borrow_mut();
337
+ data.flush_scheduled = false;
338
+ (
339
+ std::mem::take(&mut data.layout_dirty),
340
+ std::mem::take(&mut data.active_dirty),
341
+ data.panels.iter().map(|p| p.id.clone()).collect::<Vec<_>>(),
342
+ data.active.clone(),
343
+ data.layout_changed.clone(),
344
+ data.active_changed.clone(),
345
+ )
346
+ };
347
+
348
+ if layout {
349
+ layout_pubsub.emit(panels);
350
+ }
351
+
352
+ if active {
353
+ active_pubsub.emit(active_id);
354
+ }
355
+ }
356
+
280
357
  /// Stage a layout tree for `MainPanel` to apply at its next `rendered`
281
358
  /// pass (see `WorkspaceData::pending_layout`).
282
359
  pub fn set_pending_layout(&self, layout: crate::js::Layout) {
@@ -512,16 +589,22 @@ impl Workspace {
512
589
  /// Append a [`Panel`]. When the element had zero panels, the inserted panel
513
590
  /// becomes the active one (there is no other candidate).
514
591
  pub fn insert_panel(&self, panel: Panel) {
515
- let mut data = self.0.borrow_mut();
516
- if data.active.is_none() {
517
- data.active = Some(panel.id.clone());
592
+ {
593
+ let mut data = self.0.borrow_mut();
594
+ if data.active.is_none() {
595
+ data.active = Some(panel.id.clone());
596
+ data.active_dirty = true;
597
+ }
598
+
599
+ panel
600
+ .renderer
601
+ .set_active_flag(data.active.as_ref() == Some(&panel.id));
602
+ data.panels.push(panel);
603
+ data.layout_dirty = true;
604
+ Self::sync_solo_flags(&data);
518
605
  }
519
606
 
520
- panel
521
- .renderer
522
- .set_active_flag(data.active.as_ref() == Some(&panel.id));
523
- data.panels.push(panel);
524
- Self::sync_solo_flags(&data);
607
+ self.schedule_layout_flush();
525
608
  }
526
609
 
527
610
  /// Hold `panel` in the reservation slot (see [`WorkspaceData::reserved`]):
@@ -585,8 +668,10 @@ impl Workspace {
585
668
 
586
669
  if data.active.as_ref() == Some(id) {
587
670
  data.active = None;
671
+ data.active_dirty = true;
588
672
  }
589
673
 
674
+ data.layout_dirty |= removed.is_some();
590
675
  Self::sync_solo_flags(&data);
591
676
  (
592
677
  removed,
@@ -605,25 +690,32 @@ impl Workspace {
605
690
  staged_pubsub.emit(());
606
691
  }
607
692
 
693
+ self.schedule_layout_flush();
608
694
  removed
609
695
  }
610
696
 
611
697
  /// Set the active panel. Returns `false` (no-op) if `id` is not a known
612
698
  /// panel.
613
699
  pub fn set_active(&self, id: PanelId) -> bool {
614
- let mut data = self.0.borrow_mut();
615
- if data.panels.iter().any(|p| p.id == id) {
616
- data.active = Some(id);
617
- for panel in data.panels.iter() {
618
- panel
619
- .renderer
620
- .set_active_flag(data.active.as_ref() == Some(&panel.id));
700
+ let known = {
701
+ let mut data = self.0.borrow_mut();
702
+ if data.panels.iter().any(|p| p.id == id) {
703
+ data.active_dirty |= data.active.as_ref() != Some(&id);
704
+ data.active = Some(id);
705
+ for panel in data.panels.iter() {
706
+ panel
707
+ .renderer
708
+ .set_active_flag(data.active.as_ref() == Some(&panel.id));
709
+ }
710
+
711
+ true
712
+ } else {
713
+ false
621
714
  }
715
+ };
622
716
 
623
- true
624
- } else {
625
- false
626
- }
717
+ self.schedule_layout_flush();
718
+ known
627
719
  }
628
720
 
629
721
  /// The default [`Client`], if one has been loaded.
@@ -118,6 +118,22 @@ perspective-dropdown {
118
118
  --psp-label--query-tab--content: "Abfrage";
119
119
  --psp-label--plugin-tab--content: "Plugin";
120
120
  --psp-label--debug-tab--content: "JSON";
121
+ --psp-label--chat-tab--content: "Chat";
122
+
123
+ /* Chat panel */
124
+ --psp-label--chat-reasoning--content: "Denkprozess";
125
+ --psp-label--chat-send--content: "Senden";
126
+ --psp-label--chat-stop--content: "Stopp";
127
+ --psp-label--chat-placeholder--content: "Frage zu Ihren Daten…";
128
+
129
+ /* Plugin-declared pivot roles (PluginStaticConfig) */
130
+ --psp-label--role--x-axis--content: "X-Achse";
131
+ --psp-label--role--y-axis--content: "Y-Achse";
132
+ --psp-label--role--series--content: "Serien";
133
+ --psp-label--role--hierarchy--content: "Hierarchie";
134
+ --psp-label--role--facets--content: "Facetten";
135
+ --psp-label--role--row-groups--content: "Zeilengruppen";
136
+ --psp-label--role--column-groups--content: "Spaltengruppen";
121
137
 
122
138
  /* Plugin settings */
123
139
  --psp-label--edit-mode--content: "Bearbeitungsmodus";
@@ -118,6 +118,22 @@ perspective-dropdown {
118
118
  --psp-label--query-tab--content: "Consulta";
119
119
  --psp-label--plugin-tab--content: "Plugin";
120
120
  --psp-label--debug-tab--content: "JSON";
121
+ --psp-label--chat-tab--content: "Chat";
122
+
123
+ /* Chat panel */
124
+ --psp-label--chat-reasoning--content: "Razonamiento";
125
+ --psp-label--chat-send--content: "Enviar";
126
+ --psp-label--chat-stop--content: "Detener";
127
+ --psp-label--chat-placeholder--content: "Pregunta sobre tus datos…";
128
+
129
+ /* Plugin-declared pivot roles (PluginStaticConfig) */
130
+ --psp-label--role--x-axis--content: "Eje X";
131
+ --psp-label--role--y-axis--content: "Eje Y";
132
+ --psp-label--role--series--content: "Series";
133
+ --psp-label--role--hierarchy--content: "Jerarquía";
134
+ --psp-label--role--facets--content: "Facetas";
135
+ --psp-label--role--row-groups--content: "Grupos de filas";
136
+ --psp-label--role--column-groups--content: "Grupos de columnas";
121
137
 
122
138
  /* Plugin settings */
123
139
  --psp-label--edit-mode--content: "Modo de edición";