From dc28f52b9f50383782f7848069c1682075a4f764 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:05:18 -0300 Subject: [PATCH 01/25] refactor: replace markdown-editor with native vaadin markdown Swap the markdown-editor-addon MarkdownViewer for Vaadin's built-in Markdown component in ChatMessage. --- .../addons/chatassistant/ChatMessage.java | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java index 1fd1b7e..f583fa0 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java @@ -20,13 +20,14 @@ package com.flowingcode.vaadin.addons.chatassistant; import com.flowingcode.vaadin.addons.chatassistant.model.Message; -import com.flowingcode.vaadin.addons.markdown.MarkdownViewer; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.HasComponents; import com.vaadin.flow.component.Tag; import com.vaadin.flow.component.dependency.CssImport; import com.vaadin.flow.component.dependency.JsModule; import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.markdown.Markdown; + import java.time.format.DateTimeFormatter; import lombok.EqualsAndHashCode; @@ -35,7 +36,6 @@ * * @author mmlopez */ -@SuppressWarnings("serial") @JsModule("@vaadin/message-list/src/vaadin-message.js") @Tag("vaadin-message") @CssImport("./styles/fc-chat-message-styles.css") @@ -45,7 +45,7 @@ public class ChatMessage extends Component implements HasComp private T message; private boolean markdownEnabled; private Div loader; - private MarkdownViewer markdownViewer; + private Markdown markdown; /** * Creates a new ChatMessage based on the supplied message without markdown support. @@ -69,8 +69,8 @@ public ChatMessage(T message, boolean markdownEnabled) { loader.setVisible(false); this.add(loader); if (markdownEnabled) { - markdownViewer = new MarkdownViewer(message.getContent()); - this.add(markdownViewer); + markdown = new Markdown(message.getContent()); + this.add(markdown); } setMessage(message); } @@ -103,10 +103,13 @@ private void updateMessage(T message) { loader.setVisible(message.isLoading()); if (!message.isLoading()) { if (markdownEnabled) { - markdownViewer.setContent(message.getContent()); + markdown.setContent(message.getContent()); } else { - this.getElement().executeJs("[...this.childNodes].forEach(node => node.nodeType === 3 && this.removeChild(node));"); - this.getElement().executeJs("this.appendChild(document.createTextNode($0));", message.getContent()); + // Strip any stale text node left in the slot, then append the current content as fresh text. + this.getElement().executeJs( + "[...this.childNodes].forEach(node => node.nodeType === 3 && this.removeChild(node));" + + "this.appendChild(document.createTextNode($0));", + message.getContent()); } } } From 38648cb8fadda2fc0b2e96f750c505ebf6facca3 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:05:18 -0300 Subject: [PATCH 02/25] build: remove the markdown-editor-addon dependency --- pom.xml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pom.xml b/pom.xml index 5afa70e..d93bea0 100644 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,6 @@ ${drivers.dir}/chromedriver 11.0.26 5.2.0 - 2.0.3 1.18.46 1.3 3.1.0 @@ -125,11 +124,6 @@ vaadin-core true - - com.flowingcode.vaadin.addons - markdown-editor-addon - ${markdown-editor.version} - org.projectlombok lombok From a67fedbaf2299aa5018ec7f0067059424ce63a6b Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:05:18 -0300 Subject: [PATCH 03/25] build: bump development version to 5.1.0-SNAPSHOT --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index d93bea0..3212d0c 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ org.vaadin.addons.flowingcode chat-assistant-addon - 5.0.2-SNAPSHOT + 5.1.0-SNAPSHOT Chat Assistant Add-on Chat Assistant Add-on for Vaadin Flow https://www.flowingcode.com/en/open-source/ From b9d7f9030415f378f87a4376d962af9d793947f4 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:05:29 -0300 Subject: [PATCH 04/25] feat: add FabPosition and ChatAssistantMode enums --- .../model/ChatAssistantMode.java | 29 +++++++++++++++++++ .../chatassistant/model/FabPosition.java | 28 ++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/ChatAssistantMode.java create mode 100644 src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabPosition.java diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/ChatAssistantMode.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/ChatAssistantMode.java new file mode 100644 index 0000000..46daf48 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/ChatAssistantMode.java @@ -0,0 +1,29 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2024 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.chatassistant.model; + +/** + * The display mode of the chat assistant: {@link #MOBILE} opens the chat window as a full-screen + * dialog, while {@link #DESKTOP} opens it as an anchored popover. + */ +public enum ChatAssistantMode { + MOBILE, + DESKTOP; +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabPosition.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabPosition.java new file mode 100644 index 0000000..d04e926 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabPosition.java @@ -0,0 +1,28 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2024 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.chatassistant.model; + +/** The corner of the viewport where the floating action button is initially placed. */ +public enum FabPosition { + BOTTOM_RIGHT, + BOTTOM_LEFT, + TOP_RIGHT, + TOP_LEFT; +} From e3cdf064fba3eba4a2e4b6f321a5e8ef23cb3841 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:05:30 -0300 Subject: [PATCH 05/25] feat: add FAB drag and corner-positioning frontend --- .../frontend/fc-chat-assistant-movement.js | 227 +++++++++++++++--- 1 file changed, 191 insertions(+), 36 deletions(-) diff --git a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js index 4c8cdf8..f11eadf 100644 --- a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js +++ b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js @@ -17,7 +17,43 @@ * limitations under the License. * #L% */ -window.fcChatAssistantMovement = (root, item, container, fab, marginRaw, sensitivityRaw) => { +// Resolves the FAB's rendered size, falling back to the offset/CSS size when the element has not +// been laid out yet (getBoundingClientRect returns 0 before the first layout pass). +function fcChatAssistantSize(fab) { + const rect = fab.getBoundingClientRect(); + const width = rect.width || fab.offsetWidth || parseFloat(fab.style.width) || 0; + const height = rect.height || fab.offsetHeight || parseFloat(fab.style.height) || 0; + return { width, height }; +} + +// Returns the box the FAB is positioned against: the viewport when fixed, otherwise its offset +// parent (e.g. a containing div). The right/bottom offsets are relative to this box. +function fcChatAssistantBounds(item) { + if (getComputedStyle(item).position === 'fixed' || !item.offsetParent) { + return { width: window.innerWidth, height: window.innerHeight }; + } + const rect = item.offsetParent.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; +} + +// Computes the FAB's position, expressed as right/bottom offsets, for the given corner. +function fcChatAssistantCornerPosition(item, fab, corner, margin) { + const size = fcChatAssistantSize(fab); + const bounds = fcChatAssistantBounds(item); + const right = margin; + const bottom = margin; + const left = Math.max(margin, bounds.width - size.width - margin); + const top = Math.max(margin, bounds.height - size.height - margin); + switch (corner) { + case 'BOTTOM_LEFT': return { x: left, y: bottom }; + case 'TOP_RIGHT': return { x: right, y: top }; + case 'TOP_LEFT': return { x: left, y: top }; + case 'BOTTOM_RIGHT': + default: return { x: right, y: bottom }; + } +} + +window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, positionRaw) => { // Prevent duplicate initialization const guard = `__fcChatAssistantMovement`; if (item[guard]) { @@ -30,6 +66,8 @@ window.fcChatAssistantMovement = (root, item, container, fab, marginRaw, sensiti const snapTransition = 'all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275)'; const position = { x: margin, y: margin }; const initialPosition = { x: margin, y: margin }; + // Expose the live position so the reset hook can move the FAB after initialization. + item.__fcPosition = position; let screenWidth = window.innerWidth; let screenHeight = window.innerHeight; @@ -40,38 +78,9 @@ window.fcChatAssistantMovement = (root, item, container, fab, marginRaw, sensiti const resizeHandler = (_) => { screenWidth = window.innerWidth; screenHeight = window.innerHeight; - - // Adjust container dimensions to fit within screen bounds - if (container) { - const rect = container.getBoundingClientRect(); - let widthAdjustment = 0; - let heightAdjustment = 0; - if (rect.left < 0) { - widthAdjustment = Math.abs(rect.left); - } - if (rect.right > screenWidth) { - widthAdjustment = Math.max(widthAdjustment, rect.right - screenWidth); - } - if (rect.top < 0) { - heightAdjustment = Math.abs(rect.top); - } - if (rect.bottom > screenHeight) { - heightAdjustment = Math.max(heightAdjustment, rect.bottom - screenHeight); - } - // Apply adjustments - if (widthAdjustment > 0) { - const minWidth = parseFloat(container.style.minWidth) || 0; - const newWidth = Math.max(minWidth, rect.width - widthAdjustment); - container.style.width = newWidth + 'px'; - } - if (heightAdjustment > 0) { - const minHeight = parseFloat(container.style.minHeight) || 0; - const newHeight = Math.max(minHeight, rect.height - heightAdjustment); - container.style.height = newHeight + 'px'; - } - } - - // Reposition the item to ensure it stays within the new screen bounds + + // The popover content part clamps itself to the viewport (max-height/width: 100%), so no + // manual container shrinking is needed here. Just keep the FAB within the new screen bounds. snapToBoundary(); }; window.addEventListener("resize", resizeHandler); @@ -79,6 +88,8 @@ window.fcChatAssistantMovement = (root, item, container, fab, marginRaw, sensiti const origDisconnectedCallback = root.disconnectedCallback?.bind(root); root.disconnectedCallback = () => { window.removeEventListener("resize", resizeHandler); + window.fcChatAssistantMobileModeOff?.(root); + window.fcChatAssistantScreenSizeOffAll?.(root); origDisconnectedCallback?.(); }; @@ -112,7 +123,8 @@ window.fcChatAssistantMovement = (root, item, container, fab, marginRaw, sensiti } item.addEventListener('pointerdown', (e) => { - isDragging = true; + isDragging = fab.hasAttribute('movable') && fab.hasAttribute('anchored'); + if (!isDragging) return; fab.classList.add('dragging'); item.setPointerCapture(e.pointerId); item.style.transition = sizeTransition; @@ -123,13 +135,17 @@ window.fcChatAssistantMovement = (root, item, container, fab, marginRaw, sensiti item.addEventListener('pointermove', (e) => { if (!isDragging) return; const itemRect = fab.getBoundingClientRect(); - // Calculate position from right and bottom edges + // Calculate position from right and bottom edges, keeping the FAB centered on the cursor. position.x = screenWidth - e.clientX - (itemRect.width / 2); position.y = screenHeight - e.clientY - (itemRect.height / 2); - + // Do not move if delta is below sensitivity + if (isClickOnlyEvent()) { + return; + } updatePosition(); }); + item.addEventListener('click', () => onFabClick()); item.addEventListener('pointerup', (e) => stopDragging(e)); item.addEventListener('pointerleave', (e) => stopDragging(e)); item.addEventListener('pointercancel', (e) => stopDragging(e)); @@ -149,5 +165,144 @@ window.fcChatAssistantMovement = (root, item, container, fab, marginRaw, sensiti } } + function onFabClick() { + if(!fab.hasAttribute('movable') || !fab.hasAttribute('anchored')) { + root.$server?.onClick(); + } + } + + // Apply the configured corner once the FAB has a real size. getBoundingClientRect returns 0 + // before the first layout pass (and while the FAB lives in a hidden tab), which would push it + // off-screen for any corner other than the bottom-right default. An IntersectionObserver fires + // when the FAB becomes visible, so the size is known by then. + function applyCorner() { + const start = fcChatAssistantCornerPosition(item, fab, positionRaw, margin); + position.x = start.x; + position.y = start.y; + initialPosition.x = start.x; + initialPosition.y = start.y; + updatePosition(); + } + if (fcChatAssistantSize(fab).width > 0) { + applyCorner(); + } else { + const observer = new IntersectionObserver((_, obs) => { + if (fcChatAssistantSize(fab).width > 0) { + obs.disconnect(); + applyCorner(); + } + }); + observer.observe(fab); + } + updatePosition(); }; + +// Moves the FAB back to the given corner, animating the transition like a drag-snap. +window.fcChatAssistantResetPosition = (item, marginRaw, positionRaw) => { + const margin = parseFloat(marginRaw); + const fab = item.querySelector('.fc-chat-assistant-fab') || item; + const target = fcChatAssistantCornerPosition(item, fab, positionRaw, margin); + // A gentle ease-out with a small overshoot (1.1 vs the snappier 1.275 used while dragging) so the + // reset settles into the corner without bouncing. + const resetTransition = 'all 0.45s cubic-bezier(0.22, 0.61, 0.36, 1.1)'; + item.style.transition = resetTransition; + item.style.right = target.x + 'px'; + item.style.bottom = target.y + 'px'; + // Keep the live drag state in sync so the next drag starts from the reset position. + if (item.__fcPosition) { + item.__fcPosition.x = target.x; + item.__fcPosition.y = target.y; + } +}; + +// Removes any active media-query listener, freezing the component in its current mode. Also clears +// the server-side listener guard so a later re-enable re-attaches cleanly. +window.fcChatAssistantMobileModeOff = (root) => { + if (root.__fcMobileMql && root.__fcMobileHandler) { + root.__fcMobileMql.removeEventListener('change', root.__fcMobileHandler); + } + root.__fcMobileMql = null; + root.__fcMobileHandler = null; + root['fc-chat-assistant-mobile-listener'] = null; +}; + +// Watches the viewport width against the given breakpoint and notifies the server whenever the +// mobile/desktop state changes. Fires once immediately so the initial mode matches the viewport. +// A breakpoint of 0 (or less) disables mobile mode entirely (always desktop). +window.fcChatAssistantMobileMode = (root, breakpointRaw) => { + // Replace any previous listener so repeated calls (e.g. breakpoint changes) don't stack up. + window.fcChatAssistantMobileModeOff(root); + const breakpoint = parseFloat(breakpointRaw); + if (!(breakpoint > 0)) { + // Disabled: ensure desktop mode and register no listener. + root.$server?.onMobileModeChange(false); + return; + } + const mql = window.matchMedia('(max-width: ' + breakpoint + 'px)'); + const handler = (e) => root.$server?.onMobileModeChange(e.matches); + mql.addEventListener('change', handler); + root.__fcMobileMql = mql; + root.__fcMobileHandler = handler; + // Evaluate the current viewport so the initial mode is correct. + handler(mql); +}; + +// Watches the CHAT WINDOW's own size (the given overlay Div, which fills the popover content) against a +// width and/or height threshold, identified by `key`. A null threshold means that axis is not tracked; +// when both are given they must both be satisfied (AND). The server is notified only when the +// above/below state actually flips (crossing), plus once on registration with the initial state, so a +// resize within one side of the threshold sends no calls. Multiple keys coexist independently. +window.fcChatAssistantScreenSize = (root, overlayDiv, key, widthRaw, heightRaw) => { + if (!root.__fcScreenSizeListeners) { + root.__fcScreenSizeListeners = {}; + } + // Replace any previous registration under this key so repeated calls don't stack observers. + window.fcChatAssistantScreenSizeOff(root, key); + + const width = parseFloat(widthRaw); + const height = parseFloat(heightRaw); + const isAbove = (rect) => + (Number.isNaN(width) || rect.width >= width) + && (Number.isNaN(height) || rect.height >= height); + + const entry = { observer: null, last: null }; + const notify = (rect) => { + // Ignore the pre-layout 0x0 state so the initial delivery reflects the real size. + if (rect.width === 0 && rect.height === 0) { + return; + } + const above = isAbove(rect); + if (above !== entry.last) { + entry.last = above; + root.$server?.onScreenSizeChange(key, above); + } + }; + + entry.observer = new ResizeObserver((entries) => notify(entries[0].contentRect)); + entry.observer.observe(overlayDiv); + root.__fcScreenSizeListeners[key] = entry; + // Deliver the current state immediately (if already laid out; otherwise the observer's first + // callback delivers it). + notify(overlayDiv.getBoundingClientRect()); +}; + +// Removes the size observer registered under `key` (no-op if absent). +window.fcChatAssistantScreenSizeOff = (root, key) => { + const listeners = root.__fcScreenSizeListeners; + if (listeners && listeners[key]) { + listeners[key].observer?.disconnect(); + delete listeners[key]; + } + // Clear the refresh guard so the same key can be re-registered later. + root['fc-chat-assistant-screen-size-' + key] = null; +}; + +// Removes every screen-size observer (used on disconnect). +window.fcChatAssistantScreenSizeOffAll = (root) => { + const listeners = root.__fcScreenSizeListeners; + if (listeners) { + Object.keys(listeners).forEach((key) => listeners[key].observer?.disconnect()); + } + root.__fcScreenSizeListeners = {}; +}; From 41d2a49c6377280dcb9157fe7924d13367ae7854 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:05:30 -0300 Subject: [PATCH 06/25] feat: add chat window resize and sizing frontend Add the directional resize handles, window sizing with clamped min/max bounds applied to the popover content part, size persistence across close/reopen, the screen-size threshold tracking, and the resize direction indicators. --- .../frontend/fc-chat-assistant-resize.js | 180 +++++++++++++++--- .../styles/fc-chat-assistant-style.css | 89 ++++++++- 2 files changed, 245 insertions(+), 24 deletions(-) diff --git a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js index 4b9c750..7e083db 100644 --- a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js +++ b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js @@ -18,15 +18,23 @@ * #L% */ -// Combined resize functionality for all directions +// Combined resize functionality for all directions. +// `container` is the chat overlay Div (it fills the popover content part, so its rendered size is the +// current content size). Resizing writes the desired size onto the popover's public content-height/ +// content-width, which Vaadin clamps to the viewport, so the content can never overflow the popover. window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw, direction) => { - // Prevent duplicate initialization + // Prevent duplicate initialization. The handlers always attach once; whether a drag is allowed is + // decided live (see isResizable) so toggling resizable after init takes effect immediately. const guard = `__fcChatAssistantResize_${direction}`; if (item[guard]) { return; } item[guard] = true; + // The resizable state is read live at event time, not captured at init, so setWindowResizable() + // enables/disables resizing on an already-initialized handle. + const isResizable = () => container.hasAttribute('resizable'); + const size = parseFloat(sizeRaw); const maxSize = parseFloat(maxSizeRaw); const overlayTag = "vaadin-popover-overlay".toUpperCase(); @@ -35,9 +43,26 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw let minHeight = 0; let maxWidth = Infinity; let maxHeight = Infinity; - let overlay; + let overlay; // the vaadin-popover-overlay element (used for shouldDrag positioning rules) + let contentPart; // its [part='content'], which our overlay Div fills let isDragging = false; + // Write the desired size directly onto the overlay's content part. This part keeps + // overflow:auto + max-height/width:100% in both Vaadin 24 and 25, so the size is clamped to the + // viewport and the content never overflows. (Vaadin 25 removed the content-height/width API, so + // sizing the part directly is the version-agnostic approach.) + // The desired size is also stored on the (durable) overlay Div so it can be restored when the + // popover is closed and reopened, since Vaadin rebuilds the overlay's shadow DOM each time. + const sizeTarget = () => contentPart || overlay; + const setContentHeight = (px) => { + container.style.setProperty('--fc-height', px + 'px'); + sizeTarget().style.height = px + 'px'; + }; + const setContentWidth = (px) => { + container.style.setProperty('--fc-width', px + 'px'); + sizeTarget().style.width = px + 'px'; + }; + const directionConfig = { 'top': { shouldDrag: () => overlay?.style?.bottom && !overlay?.style?.top, @@ -45,7 +70,7 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const offsetY = container.getBoundingClientRect().top - e.clientY; const newHeight = offsetY + container.clientHeight; if (newHeight >= minHeight && newHeight <= maxHeight) { - container.style.height = newHeight + 'px'; + setContentHeight(newHeight); } }, setupDrag: () => { @@ -67,12 +92,12 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const offsetY = container.getBoundingClientRect().top - e.clientY; const newHeight = offsetY + container.clientHeight; if(newHeight >= minHeight && newHeight <= maxHeight) { - container.style.height = newHeight + 'px'; + setContentHeight(newHeight); } const offsetX = e.clientX - container.getBoundingClientRect().right; const newWidth = offsetX + container.clientWidth; if (newWidth >= minWidth && newWidth <= maxWidth) { - container.style.width = newWidth + 'px'; + setContentWidth(newWidth); } }, setupDrag: () => { @@ -94,7 +119,7 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const offsetX = e.clientX - container.getBoundingClientRect().right; const newWidth = offsetX + container.clientWidth; if (newWidth >= minWidth && newWidth <= maxWidth) { - container.style.width = newWidth + 'px'; + setContentWidth(newWidth); } }, setupDrag: () => { @@ -116,12 +141,12 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const offsetY = e.clientY - container.getBoundingClientRect().bottom; const newHeight = offsetY + container.clientHeight; if (newHeight >= minHeight && newHeight <= maxHeight) { - container.style.height = newHeight + 'px'; + setContentHeight(newHeight); } const offsetX = e.clientX - container.getBoundingClientRect().right; const newWidth = offsetX + container.clientWidth; if (newWidth >= minWidth && newWidth <= maxWidth) { - container.style.width = newWidth + 'px'; + setContentWidth(newWidth); } }, setupDrag: () => { @@ -143,7 +168,7 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const offsetY = e.clientY - container.getBoundingClientRect().bottom; const newHeight = offsetY + container.clientHeight; if (newHeight >= minHeight && newHeight <= maxHeight) { - container.style.height = newHeight + 'px'; + setContentHeight(newHeight); } }, setupDrag: () => { @@ -165,12 +190,12 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const offsetY = e.clientY - container.getBoundingClientRect().bottom; const newHeight = offsetY + container.clientHeight; if(newHeight >= minHeight && newHeight <= maxHeight) { - container.style.height = newHeight + 'px'; + setContentHeight(newHeight); } const offsetX = container.getBoundingClientRect().left - e.clientX; const newWidth = offsetX + container.clientWidth; if (newWidth >= minWidth && newWidth <= maxWidth) { - container.style.width = newWidth + 'px'; + setContentWidth(newWidth); } }, setupDrag: () => { @@ -192,7 +217,7 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const offsetX = container.getBoundingClientRect().left - e.clientX; const newWidth = offsetX + container.clientWidth; if (newWidth >= minWidth && newWidth <= maxWidth) { - container.style.width = newWidth + 'px'; + setContentWidth(newWidth); } }, setupDrag: () => { @@ -214,12 +239,12 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const offsetY = container.getBoundingClientRect().top - e.clientY; const newHeight = offsetY + container.clientHeight; if(newHeight >= minHeight && newHeight <= maxHeight) { - container.style.height = newHeight + 'px'; + setContentHeight(newHeight); } const offsetX = container.getBoundingClientRect().left - e.clientX; const newWidth = offsetX + container.clientWidth; if (newWidth >= minWidth && newWidth <= maxWidth) { - container.style.width = newWidth + 'px'; + setContentWidth(newWidth); } }, setupDrag: () => { @@ -239,31 +264,65 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const config = directionConfig[direction]; if (!config) { - console.error(`Invalid direction: ${direction}. Valid directions: ${Object.keys(directionConfig).join(', ')}`); + console.error(`Invalid direction: ${JSON.stringify(direction)}. Valid directions: ${Object.keys(directionConfig).join(', ')}`); return; } + // Reflects the live "can this handle be dragged right now" state as a class, so the CSS can show the + // direction arrowhead only on the handles that are currently draggable (see fc-chat-assistant-style.css). + let styleObserver = null; + function updateCanDrag() { + item.classList.toggle('fc-chat-assistant-resize-can-drag', isResizable() && config.shouldDrag()); + } + + // shouldDrag() is a pure function of the overlay's inline top/bottom/left/right, which the popover + // mutates on open and during cross-edge resizes. Also watch the container's `resizable` attribute so + // toggling setWindowResizable() shows/hides the direction indicators immediately (without a hover). + // Re-observe whenever a (new) overlay is resolved. + function observeOverlayStyle() { + styleObserver?.disconnect(); + styleObserver = new MutationObserver(updateCanDrag); + styleObserver.observe(overlay, { attributes: true, attributeFilter: ['style'] }); + styleObserver.observe(container, { attributes: true, attributeFilter: ['resizable'] }); + updateCanDrag(); + } + window.requestAnimationFrame(fetchOverlay); setTimeout(fetchOverlay, 2000); // in case the overlay is not available immediately, check again after 2 seconds - // Fetch the root overlay component + // Fetch the root overlay component and its content part. function fetchOverlay() { if (!overlay) { overlay = document.querySelector(`.${popoverTag}`)?.shadowRoot?.querySelector(overlayTag); if(!overlay) { overlay = [...document.getElementsByClassName(popoverTag)].find(p => p.tagName == overlayTag); } + if (overlay) { + observeOverlayStyle(); + } + } + if (overlay && !contentPart) { + contentPart = overlay.shadowRoot?.querySelector('[part="content"]'); } } + window.addEventListener('resize', () => updateCanDrag()); + item.addEventListener('pointerenter', (e) => { - if (config.shouldDrag()) { + updateCanDrag(); + if (isResizable() && config.shouldDrag()) { item.classList.add('active'); + // Resize bounds come from custom properties on the overlay Div (set from the Java + // setWindowMin*/Max* methods), so they don't affect the 100% Div's own layout. const computedStyle = window.getComputedStyle(container); - minHeight = computedStyle.minHeight ? parseFloat(computedStyle.minHeight) || 0 : 0; - minWidth = computedStyle.minWidth ? parseFloat(computedStyle.minWidth) || 0 : 0; - maxWidth = computedStyle.maxWidth ? parseFloat(computedStyle.maxWidth) || Infinity : Infinity; - maxHeight = computedStyle.maxHeight ? parseFloat(computedStyle.maxHeight) || Infinity : Infinity; + const bound = (prop, dflt) => { + const value = parseFloat(computedStyle.getPropertyValue(prop)); + return Number.isFinite(value) ? value : dflt; + }; + minHeight = bound('--fc-min-height', 0); + minWidth = bound('--fc-min-width', 0); + maxHeight = bound('--fc-max-height', Infinity); + maxWidth = bound('--fc-max-width', Infinity); } else { item.classList.remove('active'); @@ -271,9 +330,12 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw }); item.addEventListener('pointerdown', (e) => { - isDragging = config.shouldDrag(); + isDragging = isResizable() && config.shouldDrag(); if (isDragging) { item.setPointerCapture(e.pointerId); + // The handle grows while dragging (setupDrag), which would push the arrowhead outside the + // overlay; hide it for the duration of the resize. + item.classList.add('fc-chat-assistant-resize-resizing'); config.setupDrag(); } }); @@ -291,11 +353,83 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const wasDragging = isDragging; isDragging = false; item.classList.remove('active'); + item.classList.remove('fc-chat-assistant-resize-resizing'); if (wasDragging) { config.cleanupDrag(); if (item.hasPointerCapture(e.pointerId)) { item.releasePointerCapture(e.pointerId); } + // A resize can pin/unpin edges, changing which handles are draggable. + updateCanDrag(); } } }; + +// Resolves the popover overlay's [part='content'] element, retrying briefly because the overlay is +// (re)created lazily when the popover opens. +function fcChatAssistantContentPart(popoverTag, callback, attempts = 0) { + const overlayTag = "vaadin-popover-overlay".toUpperCase(); + const overlay = document.querySelector(`.${popoverTag}`)?.shadowRoot?.querySelector(overlayTag) + || [...document.getElementsByClassName(popoverTag)].find(p => p.tagName === overlayTag); + const contentPart = overlay?.shadowRoot?.querySelector('[part="content"]'); + if (contentPart) { + callback(contentPart); + } else if (attempts < 20) { + setTimeout(() => fcChatAssistantContentPart(popoverTag, callback, attempts + 1), 100); + } +} + +// Applies the chat window's configured size and bounds to the popover content part, sourced from the +// `--fc-*` custom properties on the durable overlay Div (which survive close/reopen; the content part is +// rebuilt each open). This is the single source of truth used on open and whenever a Java size/bound +// setter runs. The content part is sized directly (works on Vaadin 24 and 25), and the desired +// width/height are clamped into the [min, max] range so an explicit/previously-resized size that violates +// a (later-set) bound is corrected. Setting the part directly is required because a `var()` on +// ::part(content) cannot read a custom property set on the inner overlay Div (custom properties inherit +// downward, and the overlay Div is a descendant of the content part). +window.fcChatAssistantApplyConstraints = (overlayDiv, popoverTag) => { + const raw = (prop) => overlayDiv.style.getPropertyValue(prop); + const num = (prop, dflt) => { + const value = parseFloat(raw(prop)); + return Number.isFinite(value) ? value : dflt; + }; + const widthRaw = raw('--fc-width'); + const heightRaw = raw('--fc-height'); + const minWidth = raw('--fc-min-width'); + const minHeight = raw('--fc-min-height'); + const maxWidth = raw('--fc-max-width'); + const maxHeight = raw('--fc-max-height'); + + // Clamp a desired size string into [min, max] (min wins if they cross, matching CSS). + const clamp = (valueRaw, minProp, maxProp) => { + const value = parseFloat(valueRaw); + if (!Number.isFinite(value)) { + return valueRaw; + } + let result = Math.min(value, num(maxProp, Infinity)); + result = Math.max(result, num(minProp, 0)); + return result + 'px'; + }; + + fcChatAssistantContentPart(popoverTag, (contentPart) => { + if (minWidth) contentPart.style.minWidth = minWidth; + if (minHeight) contentPart.style.minHeight = minHeight; + if (maxWidth) contentPart.style.maxWidth = maxWidth; + if (maxHeight) contentPart.style.maxHeight = maxHeight; + if (widthRaw) contentPart.style.width = clamp(widthRaw, '--fc-min-width', '--fc-max-width'); + if (heightRaw) contentPart.style.height = clamp(heightRaw, '--fc-min-height', '--fc-max-height'); + }); +}; + +// Sets the chat window's initial/desired width or height. Stores it on the durable overlay Div (so it +// survives close/reopen) and (re)applies all constraints, clamping the new size to the current bounds. +window.fcChatAssistantSetWindowSize = (overlayDiv, popoverTag, dimension, value) => { + overlayDiv.style.setProperty(dimension === 'height' ? '--fc-height' : '--fc-width', value); + window.fcChatAssistantApplyConstraints(overlayDiv, popoverTag); +}; + +// Re-applies the desired size and bounds (stored on the overlay Div) to the content part. Called whenever +// the popover opens, because Vaadin rebuilds the overlay's shadow DOM and the inline size is lost. +window.fcChatAssistantRestoreWindowSize = (overlayDiv, popoverTag) => { + window.fcChatAssistantApplyConstraints(overlayDiv, popoverTag); +}; diff --git a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css index 8468fcc..6c22786 100644 --- a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css +++ b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css @@ -38,7 +38,55 @@ } .fc-chat-assistant-unread-badge { - transition: all 0.15s ease-out; + transition: all 0.12s ease-out; +} + +vaadin-popover-overlay::part(overlay) { + border-radius: var(--lumo-border-radius-l); +} + +/* The chat overlay fills this part; the part already clamps itself to the viewport + (max-height/width: 100%), so the content shrinks with the popover instead of overflowing. + The min/max bounds are applied as inline styles from JS (fcChatAssistantApplyConstraints), sourced from + the component's configured values, so they are intentionally not hardcoded here. */ +vaadin-popover-overlay.fc-chat-assistant-popover::part(content) { + display: flex; + flex-direction: column; + max-width : 100%; + max-height: 100%; +} + +vaadin-popover.fc-chat-assistant-popover::part(content) { + display: flex; + flex-direction: column; + max-width : 100%; + max-height: 100%; +} + +/* Let the chat overlay Div shrink so its VirtualList scrolls internally under the clamp. */ +vaadin-popover-overlay.fc-chat-assistant-popover::part(content) > * { + min-height: 0; + min-width: 0; +} + +/* Let the chat overlay Div shrink so its VirtualList scrolls internally under the clamp. */ +vaadin-popover.fc-chat-assistant-popover::part(content) > * { + min-height: 0; + min-width: 0; +} + +vaadin-dialog.fc-chat-assistant-dialog::part(content) { + height : 100%; + width : 100%; + display : flex; + align-items: stretch; +} + +vaadin-dialog-overlay.fc-chat-assistant-dialog::part(content) { + height : 100%; + width : 100%; + display : flex; + align-items: stretch; } /* Specific cursors for each corner */ @@ -52,3 +100,42 @@ .fc-chat-assistant-resize-top.active { cursor: n-resize; } .fc-chat-assistant-resize-left.active { cursor: w-resize; } .fc-chat-assistant-resize-right.active { cursor: e-resize; } + +/* Resize direction indicators: a subtle arrowhead pointing in each handle's drag direction. Hidden by + default; shown via setResizeIndicatorsVisible, and only on handles that can currently be dragged. */ +.fc-chat-assistant-resize-arrow { + position: absolute; + width: 0; + height: 0; + pointer-events: none; /* never steal drags from the resizer */ + display: none; + /* filled triangle pointing right by default; rotated per direction below */ + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 7px solid var(--lumo-contrast-20pct); +} + +/* Feature enabled (overlay class) AND this resizer is draggable now (can-drag on the resizer). */ +.fc-chat-assistant-resize-indicator-visible + .fc-chat-assistant-resize-can-drag + .fc-chat-assistant-resize-arrow { + display: block; +} + +/* While actively resizing, the handle grows and would push the arrowhead outside the overlay; hide it. + Specificity matches the show rule above and comes later, so it wins. */ +.fc-chat-assistant-resize-indicator-visible + .fc-chat-assistant-resize-resizing.fc-chat-assistant-resize-can-drag + .fc-chat-assistant-resize-arrow { + display: none; +} + +/* Anchor each arrow inside its resizer and rotate it to point outward (base triangle points right). */ +.fc-chat-assistant-resize-arrow-right { right: 3px; top: 50%; transform: translateY(-50%); } +.fc-chat-assistant-resize-arrow-left { left: 3px; top: 50%; transform: translateY(-50%) rotate(180deg); } +.fc-chat-assistant-resize-arrow-top { top: 3px; left: 50%; transform: translateX(-50%) rotate(-90deg); } +.fc-chat-assistant-resize-arrow-bottom { bottom: 3px; left: 50%; transform: translateX(-50%) rotate(90deg); } +.fc-chat-assistant-resize-arrow-top-right { top: 3px; right: 3px; transform: rotate(-45deg); } +.fc-chat-assistant-resize-arrow-bottom-right { bottom: 3px; right: 3px; transform: rotate(45deg); } +.fc-chat-assistant-resize-arrow-bottom-left { bottom: 3px; left: 3px; transform: rotate(135deg); } +.fc-chat-assistant-resize-arrow-top-left { top: 3px; left: 3px; transform: rotate(-135deg); } From 726c8e635d7bbd9ccfcb7ef8967a025a3db21ac1 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:05:42 -0300 Subject: [PATCH 07/25] fix: tighten spacing of markdown messages Replace the stale markdown-editor rule with rules that collapse the rendered markdown's outer block margins and tighten inter-block gaps. --- .../styles/fc-chat-message-styles.css | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-message-styles.css b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-message-styles.css index 03712b9..43f24ed 100644 --- a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-message-styles.css +++ b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-message-styles.css @@ -88,17 +88,14 @@ color: var(--lumo-secondary-text-color, var(--vaadin-text-color-secondary)); } -.wmde-markdown { - height: 100%; - margin-top: 0px; - margin-bottom: 0px; - padding-top: 0px; - padding-bottom: 0px; - display: flex; - flex-direction: column; - background: none !important; +/* Markdown messages render block HTML into the vaadin-markdown light DOM, whose default block margins + add whitespace inside and between message bubbles. Collapse the outer margins and tighten the gaps. */ +vaadin-markdown > :first-child { + margin-top: 0; } - -.language-mermaid { - padding: 0px !important; +vaadin-markdown > :last-child { + margin-bottom: 0; +} +vaadin-markdown > * + * { + margin-top: var(--lumo-space-s, 0.5rem); } From a185c4ab5d49b3ffcf48aec57c53c2280cf70fa3 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:05:42 -0300 Subject: [PATCH 08/25] feat: add FAB, window, and mode configuration to ChatAssistant Close #64 Add a Lombok @Builder constructor and extend the component with: - FAB icon (default inline chatbot icon, custom overloads), sizing, and theme variants (color pass-through; LUMO_SMALL/LARGE drive the diameter). - FAB placement: setFabPosition/resetFabPosition, margin, setFabMovable, and setFabAnchoredToViewport for bounded placement. - Window control: setWindowResizable, setResizeIndicatorsVisible, initial size, and min/max bounds honored on open and while resizing; the window shrinks to its min height and clamps to the overlay. - Display mode: setMode/setMobileMode with a full-screen mobile dialog, breakpoint auto-switching (opt-in), and addModeChangedListener. - addScreenSizeListener for chat-window size threshold crossings. --- .../addons/chatassistant/ChatAssistant.java | 1104 +++++++++++++++-- 1 file changed, 998 insertions(+), 106 deletions(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java index 16d85a4..0cfd635 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java @@ -19,10 +19,13 @@ */ package com.flowingcode.vaadin.addons.chatassistant; +import com.flowingcode.vaadin.addons.chatassistant.model.ChatAssistantMode; +import com.flowingcode.vaadin.addons.chatassistant.model.FabPosition; import com.flowingcode.vaadin.addons.chatassistant.model.Message; import com.vaadin.flow.component.AttachEvent; import com.vaadin.flow.component.ClientCallable; import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.ComponentEvent; import com.vaadin.flow.component.ComponentEventListener; import com.vaadin.flow.component.Tag; import com.vaadin.flow.component.avatar.Avatar; @@ -30,13 +33,13 @@ import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.dependency.CssImport; import com.vaadin.flow.component.dependency.JsModule; +import com.vaadin.flow.component.dialog.Dialog; import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.icon.Icon; import com.vaadin.flow.component.icon.SvgIcon; import com.vaadin.flow.component.icon.VaadinIcon; import com.vaadin.flow.component.messages.MessageInput; -import com.vaadin.flow.component.orderedlayout.FlexComponent; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.popover.Popover; @@ -46,13 +49,24 @@ import com.vaadin.flow.data.renderer.ComponentRenderer; import com.vaadin.flow.data.renderer.Renderer; import com.vaadin.flow.dom.Style; +import com.vaadin.flow.dom.Style.AlignItems; +import com.vaadin.flow.dom.Style.AlignSelf; +import com.vaadin.flow.dom.Style.Display; +import com.vaadin.flow.dom.Style.Position; import com.vaadin.flow.function.SerializableSupplier; import com.vaadin.flow.shared.Registration; +import lombok.Builder; + +import java.io.IOException; +import java.io.InputStream; import java.io.Serializable; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; /** @@ -67,12 +81,29 @@ @Tag("animated-fab") public class ChatAssistant extends Div { - protected SvgIcon fabIcon; + protected SvgIcon fabIcon = createDefaultFabIcon(); + // The icon component currently shown in the FAB; the single source of truth for icon sizing (fabIcon is + // SvgIcon-typed and becomes null for non-SvgIcon icons, so it can't be used for that). + protected Component fabIconComponent = fabIcon; + protected boolean resizable = DEFAULT_WINDOW_RESIZABLE; + protected boolean fabMovable = DEFAULT_FAB_MOVABLE; + protected ChatAssistantMode mode = DEFAULT_MODE; + protected int mobileBreakpoint = DEFAULT_MOBILE_BREAKPOINT; + protected boolean mobileModeSwitchingEnabled = false; + protected boolean fabAnchoredToViewport = DEFAULT_FAB_ANCHORED_TO_VIEWPORT; + protected boolean resizeIndicatorsVisible = DEFAULT_RESIZE_INDICATORS_VISIBLE; + protected FabPosition fabPosition = DEFAULT_POSITION; + protected int fabMargin = DEFAULT_FAB_MARGIN; + protected int fabSize = DEFAULT_FAB_SIZE; + protected ButtonVariant activeSizeVariant = null; + protected String minWidth = DEFAULT_CONTENT_MIN_WIDTH + "px"; + protected String minHeight = DEFAULT_CONTENT_MIN_HEIGHT + "px"; protected final Button fab = new Button(); protected final Div unreadBadge = new Div(); protected final Div fabWrapper = new Div(fab, unreadBadge); protected final Popover chatWindow = new Popover(); + protected final Dialog mobileChatWindow = new Dialog(); protected final Div overlay = new Div(); protected final VerticalLayout container = new VerticalLayout(); @@ -86,48 +117,86 @@ public class ChatAssistant extends Div { protected final Div resizerTopLeft = new Div(); protected static final int DEFAULT_FAB_SIZE = 60; + protected static final int DEFAULT_FAB_SMALL_SIZE = 50; + protected static final int DEFAULT_FAB_LARGE_SIZE = 72; + protected static final boolean DEFAULT_FAB_ANCHORED_TO_VIEWPORT = true; + protected static final boolean DEFAULT_RESIZE_INDICATORS_VISIBLE = false; + protected static final boolean DEFAULT_WINDOW_RESIZABLE = true; + protected static final boolean DEFAULT_FAB_MOVABLE = true; + protected static final ChatAssistantMode DEFAULT_MODE = ChatAssistantMode.DESKTOP; + protected static final int DEFAULT_MOBILE_BREAKPOINT = 768; protected static final int DEFAULT_FAB_ICON_SIZE = 40; protected static final int DEFAULT_FAB_MARGIN = 25; protected static final int DEFAULT_RESIZER_SIZE = 25; protected static final int DEFAULT_MAX_RESIZER_SIZE = 200; protected static final int DEFAULT_DRAG_SENSITIVITY = 25; + protected static final FabPosition DEFAULT_POSITION = FabPosition.BOTTOM_RIGHT; + protected static final String DEFAULT_UNREAD_BADGE_BACKGROUND = "var(--lumo-warning-color)"; + protected static final String DEFAULT_UNREAD_BADGE_COLOR = "var(--lumo-warning-text-color)"; + + protected static final int DEFAULT_CONTENT_MIN_WIDTH = 150; + protected static final int DEFAULT_CONTENT_MIN_HEIGHT = 150; + protected static final String DEFAULT_POPOVER_TAG = "fc-chat-assistant-popover"; + protected static final String DEFAULT_DIALOG_TAG = "fc-chat-assistant-dialog"; + protected static final String DEFAULT_FAB_CLASS = "fc-chat-assistant-fab"; + protected static final String DEFAULT_RESIZE_CLASS = "fc-chat-assistant-resize"; + protected static final String RESIZE_INDICATOR_VISIBLE_CLASS = + "fc-chat-assistant-resize-indicator-visible"; + protected static final String DEFAULT_UNREAD_BADGE_CLASS = "fc-chat-assistant-unread-badge"; + protected static final String DEFAULT_FAB_ICON_SRC = loadDefaultFabIconSrc(); + + protected final VirtualList content = new VirtualList<>(); + protected final List messages = new ArrayList<>(); + + protected Component headerComponent; + protected Component footerContainer; + protected MessageInput messageInput; + protected Span whoIsTyping; + protected Registration defaultSubmitListenerRegistration; + protected int unreadMessages = 0; + + private int screenSizeKeySeq = 0; + private final Map screenSizeListeners = new HashMap<>(); - private static final int DEFAULT_CONTENT_MIN_WIDTH = 150; - private static final int DEFAULT_CONTENT_MIN_HEIGHT = 150; - private static final String DEFAULT_POPOVER_TAG = "fc-chat-assistant-popover"; - private static final String DEFAULT_FAB_CLASS = "fc-chat-assistant-fab"; - private static final String DEFAULT_RESIZE_CLASS = "fc-chat-assistant-resize"; - private static final String DEFAULT_UNREAD_BADGE_CLASS = "fc-chat-assistant-unread-badge"; - - private Component headerComponent; - private Component footerContainer; - private final VirtualList content; - private final List messages; - private MessageInput messageInput; - private Span whoIsTyping; - private Registration defaultSubmitListenerRegistration; - private int unreadMessages = 0; - + /** + * Creates a ChatAssistant with the given initial messages, using the defaults for every other + * setting. The messages are copied; later changes to the supplied list are not reflected. + *

+ * To configure multiple aspects at construction time, prefer {@code ChatAssistant.builder()}. + * + * @param messages the initial messages + * @param markdownEnabled flag to enable or disable markdown support + */ public ChatAssistant(List messages, boolean markdownEnabled) { - this.setUI(); - - this.content = new VirtualList<>(); - this.messages = messages; - this.initializeHeader(); - this.initializeFooter(); - this.initializeContent(markdownEnabled); - this.initializeChatWindow(); + this( + null, + null, + null, + null, + null, + null, + markdownEnabled, + messages, + null, + null, + null, + null + ); } /** - * Default constructor. Creates a ChatAssistant with no messages. + * Creates a ChatAssistant with no messages, using the defaults for every setting. + *

+ * To configure multiple aspects at construction time, prefer {@code ChatAssistant.builder()}. */ public ChatAssistant() { this(new ArrayList<>(), false); } /** - * Creates a ChatAssistant with no messages. + * Creates a ChatAssistant with no messages, using the defaults for every other setting. + *

+ * To configure multiple aspects at construction time, prefer {@code ChatAssistant.builder()}. * * @param markdownEnabled flag to enable or disable markdown support */ @@ -135,52 +204,121 @@ public ChatAssistant(boolean markdownEnabled) { this(new ArrayList<>(), markdownEnabled); } - @Override - protected void onAttach(AttachEvent attachEvent) { - super.onAttach(attachEvent); - addComponentRefreshedListener( - "fc-chat-assistant-drag-listener", - "window.fcChatAssistantMovement($0, $1, $2, $3, $4, $5);", - this.getElement(), fabWrapper.getElement(), overlay, fab.getElement(), DEFAULT_FAB_MARGIN, - DEFAULT_DRAG_SENSITIVITY - + /** + * Builder constructor. Every configurable aspect is optional; when a value is {@code null} or + * invalid it falls back to its corresponding default. + * + * @param fabIcon the FAB icon ({@code null} keeps the default chatbot icon) + * @param resizable whether the chat window is resizable (default {@value #DEFAULT_WINDOW_RESIZABLE}) + * @param fabMovable whether the FAB can be dragged (default {@value #DEFAULT_FAB_MOVABLE}) + * @param mobileBreakpoint the maximum screen width in pixels below which mobile mode is activated + * automatically. Providing any non-null value enables automatic switching (disabled by + * default); {@code null} leaves it disabled, and {@code 0} enables switching but keeps the + * component in desktop mode at any width + * @param fabAnchoredToViewport whether the FAB is anchored to the viewport ({@code true}, the default) + * or positioned within its container ({@code false}) (default {@value #DEFAULT_FAB_ANCHORED_TO_VIEWPORT}) + * @param resizeIndicatorsVisible whether the resize handles show a direction arrowhead (default + * {@value #DEFAULT_RESIZE_INDICATORS_VISIBLE}) + * @param markdownEnabled whether markdown is enabled in messages + * @param messages the initial messages ({@code null} starts empty) + * @param defaultFabPosition the FAB's initial corner (default {@link FabPosition#BOTTOM_RIGHT}) + * @param defaultFabMargin the FAB's margin to the viewport edges in pixels (default {@value #DEFAULT_FAB_MARGIN}) + * @param minWidth the chat window minimum width ({@code null} keeps the default) + * @param minHeight the chat window minimum height ({@code null} keeps the default) + */ + @Builder + public ChatAssistant( + SvgIcon fabIcon, + Boolean resizable, + Boolean fabMovable, + Integer mobileBreakpoint, + Boolean fabAnchoredToViewport, + Boolean resizeIndicatorsVisible, + Boolean markdownEnabled, + List messages, + FabPosition defaultFabPosition, + String defaultFabMargin, + String minWidth, + String minHeight + ) { + if (messages != null) { + this.messages.addAll(messages); + } + this.setUI( + fabIcon, + resizable, + fabMovable, + mobileBreakpoint, + fabAnchoredToViewport, + resizeIndicatorsVisible, + defaultFabPosition, + defaultFabMargin, + minWidth, + minHeight ); + this.initializeHeader(); + this.initializeFooter(); + this.initializeContent(markdownEnabled != null && markdownEnabled); + this.initializeChatWindow(); } - private void setUI() { - getStyle() - .setZIndex(1000); + /** + * Initializes the UI applying the given configuration. Any {@code null} or invalid value falls + * back to its corresponding default. + */ + private void setUI( + SvgIcon fabIcon, + Boolean resizable, + Boolean fabMovable, + Integer mobileBreakpoint, + Boolean fabAnchoredToViewport, + Boolean resizeIndicatorsVisible, + FabPosition fabPosition, + String fabMargin, + String minWidth, + String minHeight + ) { + String fontSize = "var(--lumo-font-size-xs)"; + getStyle().setZIndex(1000); + // The overlay fills the popover content part (which Vaadin clamps to the viewport); resizing + // writes the desired size onto the popover content, never onto this Div, so it can't overflow. + // The resize bounds live in custom properties read by the resize script (setting them here has + // no layout effect on the 100% Div, avoiding any overflow). overlay.getStyle() - .setMinHeight(DEFAULT_CONTENT_MIN_HEIGHT + "px") - .setMinWidth(DEFAULT_CONTENT_MIN_WIDTH + "px"); - - fabIcon = new SvgIcon("/icons/chatbot.svg"); - fabIcon.setSize(DEFAULT_FAB_ICON_SIZE + "px"); - - fab.getStyle() - .setBorderRadius("50%") - .setMinHeight(DEFAULT_FAB_SIZE + "px") - .setMinWidth(DEFAULT_FAB_SIZE + "px") - .setHeight(DEFAULT_FAB_SIZE + "px") - .setWidth(DEFAULT_FAB_SIZE + "px") - .setMaxHeight(DEFAULT_FAB_SIZE + "px") - .setMaxWidth(DEFAULT_FAB_SIZE + "px"); - fab.setIcon(fabIcon); + .setDisplay(Display.FLEX) + .setAlignItems(AlignItems.STRETCH) + .set("flex", "1") + .setMaxHeight("100%") + .setBoxSizing(Style.BoxSizing.BORDER_BOX) + .set("--fc-min-width", DEFAULT_CONTENT_MIN_WIDTH + "px") + .set("--fc-min-height", DEFAULT_CONTENT_MIN_HEIGHT + "px"); + + mobileChatWindow.setSizeFull(); + mobileChatWindow.setModal(false); + mobileChatWindow.setDraggable(false); + mobileChatWindow.setResizable(false); + mobileChatWindow.addClassName(DEFAULT_DIALOG_TAG); + + this.fabIcon = fabIcon != null ? fabIcon : createDefaultFabIcon(); + this.fabIconComponent = this.fabIcon; + + fab.getStyle().setBorderRadius("50%"); + fab.setIcon(this.fabIcon); fab.addClassName(DEFAULT_FAB_CLASS); fab.addThemeVariants(ButtonVariant.LUMO_PRIMARY); fabWrapper.getStyle() - .setHeight(DEFAULT_FAB_SIZE + "px") - .setWidth(DEFAULT_FAB_SIZE + "px") .setDisplay(Style.Display.INLINE_FLEX) .setAlignItems(Style.AlignItems.CENTER) .setJustifyContent(Style.JustifyContent.CENTER) .setPosition(Style.Position.FIXED); + // Apply the FAB diameter and icon size (the single sizing code path). + setFabSize(DEFAULT_FAB_SIZE); + unreadBadge.setText(String.valueOf(unreadMessages)); unreadBadge.addClassName(DEFAULT_UNREAD_BADGE_CLASS); - String fontSize = "var(--lumo-font-size-xs)"; unreadBadge.getStyle() .setTextAlign(Style.TextAlign.CENTER) .setPosition(Style.Position.ABSOLUTE) @@ -271,13 +409,91 @@ private void setUI() { resizerLeft, resizerTopLeft, resizerBottomLeft, container ); - add(chatWindow, fabWrapper); + + this.fabPosition = fabPosition != null ? fabPosition : DEFAULT_POSITION; + this.fabMargin = parseFabMargin(fabMargin); + + if (minWidth != null) { + setWindowMinWidth(minWidth); + } + if (minHeight != null) { + setWindowMinHeight(minHeight); + } + + // Auto-switching is opt-in: it is enabled only when the user explicitly defines a breakpoint in + // the constructor (or later via setMobileModeSwitchingEnabled). This avoids a breaking change and + // ensures mobile mode is only used when the user has prepared for it (e.g. a dialog close button). + if (mobileBreakpoint != null) { + this.mobileBreakpoint = Math.max(mobileBreakpoint, 0); + this.mobileModeSwitchingEnabled = true; + } + + setFabMovable(fabMovable != null ? fabMovable : DEFAULT_FAB_MOVABLE); + setWindowResizable(resizable != null ? resizable : DEFAULT_WINDOW_RESIZABLE); + setFabAnchoredToViewport(fabAnchoredToViewport != null ? fabAnchoredToViewport : DEFAULT_FAB_ANCHORED_TO_VIEWPORT); + setResizeIndicatorsVisible(resizeIndicatorsVisible != null ? resizeIndicatorsVisible : DEFAULT_RESIZE_INDICATORS_VISIBLE); + + add(chatWindow, fabWrapper, mobileChatWindow); } - /** Receives click events from the client side to toggle the chat window's opened state. */ + /** Parses the given margin value, falling back to {@value #DEFAULT_FAB_MARGIN} when null or invalid. */ + private int parseFabMargin(String fabMargin) { + if (fabMargin == null) { + return DEFAULT_FAB_MARGIN; + } + try { + return Integer.parseInt(fabMargin.trim().replace("px", "")); + } catch (NumberFormatException e) { + return DEFAULT_FAB_MARGIN; + } + } + + @Override + protected void onAttach(AttachEvent attachEvent) { + super.onAttach(attachEvent); + addComponentRefreshedListener( + "fc-chat-assistant-drag-listener", + "window.fcChatAssistantMovement($0, $1, $2, $3, $4, $5);", + this.getElement(), fabWrapper.getElement(), fab.getElement(), fabMargin, + DEFAULT_DRAG_SENSITIVITY, fabPosition.name() + + ); + if (mobileModeSwitchingEnabled) { + addComponentRefreshedListener( + "fc-chat-assistant-mobile-listener", + "window.fcChatAssistantMobileMode($0, $1);", + this.getElement(), mobileBreakpoint + ); + } + // (Re)establish any screen-size observers registered before attach (and after a reattach). They + // are also re-applied when the popover opens, since the overlay's content is rebuilt each time. + screenSizeListeners.keySet().forEach(this::applyScreenSizeListener); + } + + /** Receives mobile mode changes from the client when the viewport crosses the breakpoint. */ + @ClientCallable + protected void onMobileModeChange(boolean mobile) { + if (mobileModeSwitchingEnabled) { + setMode(mobile ? ChatAssistantMode.MOBILE : ChatAssistantMode.DESKTOP, true); + } + } + + /** Receives chat-window size threshold crossings from the client and dispatches to the matching listener. */ + @ClientCallable + protected void onScreenSizeChange(int key, boolean matches) { + ScreenSizeListenerEntry entry = screenSizeListeners.get(key); + if (entry == null) { + // The listener was removed between client setup and this callback; ignore. + return; + } + entry.listener.onComponentEvent( + new ScreenSizeEvent(this, true, entry.width, entry.height, matches)); + } + + /** Toggles the chat window's opened state. Called from the client on FAB click. */ @ClientCallable protected void onClick() { - if(isOpened()) { + if (isOpened()) { close(); } else { @@ -291,18 +507,34 @@ protected void applyGenericResizerStyle(Div resizer, String direction) { .setPosition(Style.Position.ABSOLUTE) .setDisplay(Style.Display.INLINE_BLOCK) .setZIndex(1001); - resizer.addClassName(DEFAULT_RESIZE_CLASS + "-" + direction); + setResizerClass(resizer, direction, DEFAULT_WINDOW_RESIZABLE); + // A subtle arrowhead pointing in this resizer's drag direction. Hidden until the feature is enabled + // (setResizeIndicatorsVisible) and only shown while the resizer is actually draggable (the resize + // script toggles a class for that); see fc-chat-assistant-style.css. + Div arrow = new Div(); + arrow.addClassName(DEFAULT_RESIZE_CLASS + "-arrow"); + arrow.addClassName(DEFAULT_RESIZE_CLASS + "-arrow-" + direction); + resizer.add(arrow); + } + + protected void setResizerClass(Div resizer, String direction, boolean resizable) { + String classname = DEFAULT_RESIZE_CLASS + "-" + direction; + if (resizable && !resizer.getClassNames().contains(classname)) { + resizer.addClassName(classname); + } + else if(!resizable && resizer.getClassNames().contains(classname)) { + resizer.removeClassName(classname); + } } /** - * Adds a component refresh listener that prevents stacking up duplicate listeners on the client side. - * Uses a unique flag to track if the listener has already been added for this component instance, - * ensuring the callback only executes once per component refresh cycle. + * Runs the given JavaScript once per component instance, using a flag on the element to avoid + * registering duplicate client-side listeners across refreshes. * - * @param uniqueFlag a unique identifier for the component instance - * @param executable the JavaScript action to execute when the component is refreshed, - * @param parameters parameters for the executable + * @param uniqueFlag a unique identifier for this registration + * @param executable the JavaScript to execute + * @param parameters parameters for the executable */ protected void addComponentRefreshedListener(String uniqueFlag, String executable, Serializable... parameters) { this.getElement().executeJs( @@ -317,28 +549,143 @@ protected void addComponentRefreshedListener(String uniqueFlag, String executabl ); } - /** Sets the icon for the floating action button. - *
The icon's size is automatically adjusted to fit within the FAB. */ + /** + * Creates the default chatbot icon. The SVG is read from the classpath and inlined as a data URI, so + * it does not depend on a statically served path: it works both in the demo and when the add-on is + * packaged as a jar, and (unlike a StreamResource) needs no UI/session, keeping the icon serializable. + */ + protected static SvgIcon createDefaultFabIcon() { + return new SvgIcon(DEFAULT_FAB_ICON_SRC); + } + + /** Loads the bundled chatbot SVG from the classpath and encodes it as a data URI. */ + private static String loadDefaultFabIconSrc() { + try (InputStream in = ChatAssistant.class.getResourceAsStream("/META-INF/resources/icons/chatbot.svg")) { + byte[] svg = in.readAllBytes(); + return "data:image/svg+xml;base64," + Base64.getEncoder().encodeToString(svg); + } catch (IOException | NullPointerException e) { + throw new IllegalStateException("Could not load the default chatbot icon", e); + } + } + + /** + * Sets the icon for the floating action button. The icon's size is automatically adjusted to fit + * within the current FAB size. + * + * @param icon the icon component, it cannot be null + */ public void setFabIcon(Component icon) { Objects.requireNonNull(icon, "Icon cannot be null"); - icon.getStyle() - .setWidth(DEFAULT_FAB_ICON_SIZE + "px") - .setHeight(DEFAULT_FAB_ICON_SIZE + "px"); + this.fabIcon = icon instanceof SvgIcon ? (SvgIcon) icon : null; + this.fabIconComponent = icon; + applyIconSize(icon, getFabIconSize()); fab.setIcon(icon); } - /** Sets the icon for the floating action button. Allows customizing the icon's size. */ + /** + * Sets the icon for the floating action button with a custom size. The size is capped at the current + * FAB size. + * + * @param icon the icon component, it cannot be null + * @param size the icon size in pixels, it must be greater than 0 + */ public void setFabIcon(Component icon, int size) { Objects.requireNonNull(icon, "Icon cannot be null"); if (size <= 0) { throw new IllegalArgumentException("Size must be greater than 0"); } - icon.getStyle() - .setWidth(Math.min(size, DEFAULT_FAB_SIZE) + "px") - .setHeight(Math.min(size, DEFAULT_FAB_SIZE) + "px"); + this.fabIcon = icon instanceof SvgIcon ? (SvgIcon) icon : null; + this.fabIconComponent = icon; + applyIconSize(icon, Math.min(size, fabSize)); fab.setIcon(icon); } + /** + * Sets the FAB diameter in pixels, scaling the icon to match. This is the single sizing entry point; + * the theme-variant API uses it to apply the {@link ButtonVariant#LUMO_SMALL}/{@code LUMO_LARGE} sizes. + * + * @param size the FAB diameter in pixels, it must be greater than 0 + */ + protected void setFabSize(int size) { + if (size <= 0) { + throw new IllegalArgumentException("Size must be greater than 0"); + } + this.fabSize = size; + fab.getStyle() + .setMinHeight(size + "px") + .setMinWidth(size + "px") + .setHeight(size + "px") + .setWidth(size + "px") + .setMaxHeight(size + "px") + .setMaxWidth(size + "px"); + fabWrapper.getStyle() + .setHeight(size + "px") + .setWidth(size + "px"); + if (fabIconComponent != null) { + applyIconSize(fabIconComponent, getFabIconSize()); + } + } + + /** The icon size that fits the current FAB size. */ + private int getFabIconSize() { + return Math.max(0, fabSize - 20); + } + + /** + * Pins a deterministic pixel size on the FAB icon, regardless of its concrete component type. Both + * width/height and min/max are set so the size is exact: the default chatbot SVG declares an intrinsic + * size and a {@code vaadin-icon} carries a Lumo {@code em}-based size, either of which would otherwise + * leak through and make the rendered size depend on prior state. + */ + private void applyIconSize(Component icon, int px) { + icon.getStyle() + .setWidth(px + "px").setHeight(px + "px") + .setMinWidth(px + "px").setMinHeight(px + "px") + .setMaxWidth(px + "px").setMaxHeight(px + "px"); + } + + private static boolean isSizeVariant(ButtonVariant variant) { + return variant == ButtonVariant.LUMO_SMALL || variant == ButtonVariant.LUMO_LARGE; + } + + /** + * Adds the given theme variants to the FAB. Color and style variants are applied to the underlying + * button; the size variants {@link ButtonVariant#LUMO_SMALL} and {@link ButtonVariant#LUMO_LARGE} + * instead resize the FAB (and its icon) to a predefined diameter. If both size variants are added the + * last one wins. + * + * @param variants the variants to add + */ + public void addFabThemeVariants(ButtonVariant... variants) { + for (ButtonVariant variant : variants) { + if (isSizeVariant(variant)) { + activeSizeVariant = variant; + setFabSize(variant == ButtonVariant.LUMO_LARGE ? DEFAULT_FAB_LARGE_SIZE : DEFAULT_FAB_SMALL_SIZE); + } else { + fab.addThemeVariants(variant); + } + } + } + + /** + * Removes the given theme variants from the FAB. Removing the currently active size variant + * ({@link ButtonVariant#LUMO_SMALL}/{@code LUMO_LARGE}) resets the FAB to its default size. + * + * @param variants the variants to remove + */ + public void removeFabThemeVariants(ButtonVariant... variants) { + for (ButtonVariant variant : variants) { + if (isSizeVariant(variant)) { + if (variant == activeSizeVariant) { + activeSizeVariant = null; + setFabSize(DEFAULT_FAB_SIZE); + } + } else { + fab.removeThemeVariants(variant); + } + } + } + /** Sets the opened state of the chat window. If true, opens the window; if false, closes it. */ public void setOpened(boolean opened) { if(opened) { @@ -351,47 +698,272 @@ public void setOpened(boolean opened) { /** Opens the chat window. */ public void open() { - chatWindow.open(); + if (isMobile()) { + mobileChatWindow.open(); + } + else { + chatWindow.open(); + } } /** Closes the chat window. */ public void close() { - chatWindow.close(); + if (isMobile()) { + mobileChatWindow.close(); + } + else { + chatWindow.close(); + } } /** Returns true if the chat window is opened, false otherwise. */ public boolean isOpened() { - return chatWindow.isOpened(); + return isMobile() ? mobileChatWindow.isOpened() : chatWindow.isOpened(); + } + + /** Returns true if the component is currently in {@link ChatAssistantMode#MOBILE} mode. */ + protected boolean isMobile() { + return this.mode == ChatAssistantMode.MOBILE; + } + + /** Sets whether the chat window is resizable. */ + public void setWindowResizable(boolean resizable) { + this.resizable = resizable; + if(resizable) { + overlay.getElement().setAttribute("resizable", true); + } else { + overlay.getElement().removeAttribute("resizable"); + } + setResizerClass(resizerTop, "top", resizable); + setResizerClass(resizerBottom, "bottom", resizable); + setResizerClass(resizerLeft, "left", resizable); + setResizerClass(resizerRight, "right", resizable); + setResizerClass(resizerTopLeft, "top-left", resizable); + setResizerClass(resizerTopRight, "top-right", resizable); + setResizerClass(resizerBottomLeft, "bottom-left", resizable); + setResizerClass(resizerBottomRight, "bottom-right", resizable); + } + + /** Returns true if the chat window is resizable, false otherwise. **/ + public boolean isWindowResizable() { + return resizable; + } + + /** + * Sets whether a small arrowhead is shown on each resize handle, pointing in that handle's resize + * direction, to hint where the chat window can be dragged. The indicators are subtle (a + * {@code --lumo-contrast-20pct} triangle), hidden by default, and only shown on the handles that can + * currently be dragged given the window's position. + * + * @param visible whether the resize direction indicators are visible + */ + public void setResizeIndicatorsVisible(boolean visible) { + this.resizeIndicatorsVisible = visible; + overlay.getElement().getClassList().set(RESIZE_INDICATOR_VISIBLE_CLASS, visible); + } + + /** Returns true if the resize direction indicators are visible, false otherwise. */ + public boolean isResizeIndicatorsVisible() { + return resizeIndicatorsVisible; + } + + /** Sets whether the FAB is movable. **/ + public void setFabMovable(boolean movable) { + this.fabMovable = movable; + if(movable) { + fab.getElement().setAttribute("movable", true); + } else { + fab.getElement().removeAttribute("movable"); + } + } + + /** Returns true if the FAB is movable, false otherwise. **/ + public boolean isFabMovable() { + return fabMovable; } - /** Sets the chat window minimum width. Applies when resizing. **/ + /** + * Sets whether the FAB is anchored to the viewport. When {@code true} (the default) the FAB floats + * over the viewport; when {@code false} it is positioned within its container, so it can be placed + * inside a bounded element. A FAB that is not anchored to the viewport is not movable. + */ + public void setFabAnchoredToViewport(boolean anchoredToViewport) { + this.fabAnchoredToViewport = anchoredToViewport; + fabWrapper.getStyle().setPosition(anchoredToViewport ? Position.FIXED : Position.ABSOLUTE); + if(anchoredToViewport) { + fab.getElement().setAttribute("anchored", true); + } + else { + fab.getElement().removeAttribute("anchored"); + } + } + + /** Returns true if the FAB is anchored to the viewport, false if positioned within its container. **/ + public boolean isFabAnchoredToViewport() { + return fabAnchoredToViewport; + } + + /** + * Moves the FAB to the given corner. This also becomes the position the FAB returns to when + * {@link #resetFabPosition()} is called. + * + * @param fabPosition the corner to move the FAB to, it cannot be null + */ + public void setFabPosition(FabPosition fabPosition) { + Objects.requireNonNull(fabPosition, "Position cannot be null"); + this.fabPosition = fabPosition; + resetFabPosition(); + } + + /** Returns the FAB's configured corner. */ + public FabPosition getFabPosition() { + return fabPosition; + } + + /** Moves the FAB back to its configured corner. */ + public void resetFabPosition() { + this.getElement().executeJs( + "window.fcChatAssistantResetPosition($0, $1, $2);", + fabWrapper.getElement(), fabMargin, fabPosition.name()); + } + + /** + * Sets the chat window minimum width, the lower bound enforced while resizing. + * + * @param minWidth the minimum width as a CSS length (e.g. "150px") + */ public void setWindowMinWidth(String minWidth) { - this.overlay.setMinWidth(minWidth); + this.minWidth = minWidth; + this.overlay.getStyle().set("--fc-min-width", minWidth); + applyWindowConstraints(); } - /** Sets the chat window minimum height. Applies when resizing. **/ + /** + * Sets the chat window minimum width, the lower bound enforced while resizing. + * + * @param minWidth the minimum width in px (e.g. 150) + */ + public void setWindowMinWidth(int minWidth) { + this.minWidth = String.valueOf(minWidth) + "px"; + this.overlay.getStyle().set("--fc-min-width", this.minWidth); + applyWindowConstraints(); + } + + /** + * Sets the chat window minimum height, the lower bound enforced while resizing. + * + * @param minHeight the minimum height in px (e.g. 150) + */ + public void setWindowMinHeight(int minHeight) { + this.minHeight = String.valueOf(minHeight) + "px"; + this.overlay.getStyle().set("--fc-min-height", this.minHeight); + applyWindowConstraints(); + } + + /** + * Sets the chat window minimum height, the lower bound enforced while resizing. + * + * @param minHeight the minimum height as a CSS length (e.g. "150px") + */ public void setWindowMinHeight(String minHeight) { - this.overlay.setMinHeight(minHeight); + this.minHeight = minHeight; + this.overlay.getStyle().set("--fc-min-height", minHeight); + applyWindowConstraints(); } - /** Sets the chat window maximum width. Applies when resizing. **/ + /** + * Sets the chat window maximum width, the upper bound enforced while resizing. + * + * @param maxWidth the maximum width as a CSS length + */ public void setWindowMaxWidth(String maxWidth) { - this.overlay.setMaxWidth(maxWidth); + this.overlay.getStyle().set("--fc-max-width", maxWidth); + applyWindowConstraints(); + } + + /** + * Sets the chat window maximum width, the upper bound enforced while resizing. + * + * @param maxWidth the maximum width in px (e.g. 150) + */ + public void setWindowMaxWidth(int maxWidth) { + this.overlay.getStyle().set("--fc-max-width", String.valueOf(maxWidth) + "px"); + applyWindowConstraints(); } - /** Sets the chat window maximum height. Applies when resizing. **/ + /** + * Sets the chat window maximum height, the upper bound enforced while resizing. + * + * @param maxHeight the maximum height as a CSS length + */ public void setWindowMaxHeight(String maxHeight) { - this.overlay.setMaxHeight(maxHeight); + this.overlay.getStyle().set("--fc-max-height", maxHeight); + applyWindowConstraints(); } - /** Sets the chat window default height. Applies when resizing. **/ + /** + * Sets the chat window maximum height, the upper bound enforced while resizing. + * + * @param maxHeight the maximum height in px (e.g. 150) + */ + public void setWindowMaxHeight(int maxHeight) { + this.overlay.getStyle().set("--fc-max-height", String.valueOf(maxHeight) + "px"); + applyWindowConstraints(); + } + + /** + * Sets the chat window's initial height. Use absolute units (e.g. "400px"). + * + * @param height the height as an absolute CSS length + */ public void setWindowHeight(String height) { - this.overlay.setHeight(height); + applyWindowSize("height", height); + } + + /** + * Sets the chat window's initial height. Use absolute units (e.g. 400). + * + * @param height the height in px (e.g. 400) + */ + public void setWindowHeight(int height) { + applyWindowSize("height", String.valueOf(height) + "px"); } - /** Sets the chat window default width. Applies when resizing. **/ + /** + * Sets the chat window's initial width. Use absolute units (e.g. "400px"). + * + * @param width the width as an absolute CSS length + */ public void setWindowWidth(String width) { - this.overlay.setWidth(width); + applyWindowSize("width", width); + } + + /** + * Sets the chat window's initial width. Use absolute units (e.g. 400). + * + * @param width the width in px (e.g. 400) + */ + public void setWindowWidth(int width) { + applyWindowSize("width", String.valueOf(width) + "px"); + } + + /** Sizes the popover content part (works on Vaadin 24 and 25). */ + private void applyWindowSize(String dimension, String value) { + if (value != null) { + this.getElement().executeJs( + "window.fcChatAssistantSetWindowSize($0, $1, $2, $3);", + overlay, DEFAULT_POPOVER_TAG, dimension, value + ); + } + } + + /** + * Re-applies the configured size and min/max bounds to the popover content part. Safe to call while the + * window is closed (it no-ops until the content part exists, then applies on the next open). + */ + private void applyWindowConstraints() { + this.getElement().executeJs( + "window.fcChatAssistantApplyConstraints($0, $1);", overlay, DEFAULT_POPOVER_TAG); } protected void initializeHeader() { @@ -407,20 +979,24 @@ protected void initializeHeader() { @SuppressWarnings("unchecked") protected void initializeFooter() { this.messageInput = new MessageInput(); - this.messageInput.setWidthFull(); - this.messageInput.setMaxHeight("80px"); - this.messageInput.getStyle().set("padding", "0"); + this.messageInput.getStyle() + .setMaxHeight("80px") + .set("width", "100%") + .setPadding("0 2px"); // Account for border when focused (it will get cropped otherwise) + this.defaultSubmitListenerRegistration = this.messageInput.addSubmitListener((se) -> this.sendMessage( (T) Message.builder().messageTime( LocalDateTime.now()).name("User").content(se.getValue()).build())); this.whoIsTyping = new Span(); this.whoIsTyping.setClassName("chat-assistant-who-is-typing"); this.whoIsTyping.setVisible(false); + VerticalLayout footer = new VerticalLayout(this.whoIsTyping, this.messageInput); footer.setWidthFull(); footer.setSpacing(false); footer.setMargin(false); footer.setPadding(false); + this.footerContainer = footer; } @@ -434,14 +1010,24 @@ protected void initializeContent(boolean markdownEnabled) { }) ); this.content.setItems(this.messages); - this.content.setSizeFull(); + // Allow the content to shrink below its intrinsic size so the popover clamp produces an internal + // scroll instead of overflowing (the standard flexbox min-content fix). + this.content.getStyle() + .set("flex", "1") + .setMinHeight("0"); this.container.add(this.headerComponent, this.content, this.footerContainer); this.container.setPadding(true); this.container.setMargin(false); this.container.setSpacing(false); this.container.setSizeFull(); - this.container.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN); - this.container.setFlexGrow(1.0, this.content); + this.container.getStyle() + .set("flex", "1") + .setHeight(null) + // Allow the column to shrink below its min-content height so the configured/min window height wins + // and the message list scrolls internally instead of forcing the window taller. + .set("min-height", "0") + .setAlignItems(AlignItems.STRETCH) + .setAlignSelf(AlignSelf.STRETCH); } protected void initializeChatWindow() { @@ -449,6 +1035,13 @@ protected void initializeChatWindow() { this.chatWindow.setCloseOnOutsideClick(false); this.chatWindow.addOpenedChangeListener(ev -> { if (ev.isOpened()) { + // The overlay (and its content part) is recreated on each open, so re-apply the last size + // (the configured initial size or whatever the user resized to), stored on the overlay Div. + this.getElement().executeJs( + "window.fcChatAssistantRestoreWindowSize($0, $1);", overlay, DEFAULT_POPOVER_TAG); + // Re-establish chat-window size observers against the freshly laid-out overlay and re-deliver + // the current state for this open. + screenSizeListeners.keySet().forEach(this::applyScreenSizeListener); addComponentRefreshedListener( "fc-chat-assistant-resize-top-listener", "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'top');", @@ -547,7 +1140,7 @@ public Registration setSubmitListener(ComponentEventListener renderer) { - Objects.requireNonNull(renderer, "Renderer cannot not be null"); + Objects.requireNonNull(renderer, "Renderer cannot be null"); this.content.setRenderer(renderer); } @@ -693,16 +1286,19 @@ public void setAvatarProvider(SerializableSupplier avatarProvider) { setFabIcon(avatar); } - /** - * Return the number of unread messages displayed in the chat assistant. - * @return the number of unread messages - */ + /** + * Returns the number of unread messages displayed in the chat assistant. + * + * @return the number of unread messages + */ public int getUnreadMessages() { return Math.max(unreadMessages, 0); } /** - * Sets the number of unread messages to be displayed in the chat assistant. + * Sets the number of unread messages shown on the FAB badge. The value is clamped to the 0–99 + * range; the badge is hidden when it is 0. + * * @param unreadMessages the number of unread messages to set */ public void setUnreadMessages(int unreadMessages) { @@ -715,4 +1311,300 @@ public void setUnreadMessages(int unreadMessages) { unreadBadge.getStyle().setScale("0"); } } + + /** + * Sets the background and text color of the unread badge. If null or empty, the default values are used. + * + * @param background the background color of the unread badge + * @param color the text color of the unread badge + */ + public void setUnreadBadgeColors(String background, String color) { + if(background != null && !background.isBlank()) { + unreadBadge.getStyle().set("background-color", background); + } + else { + unreadBadge.getStyle().set("background-color", DEFAULT_UNREAD_BADGE_BACKGROUND); + } + if(color != null && !color.isEmpty()) { + unreadBadge.getStyle().set("color", color); + } + else { + unreadBadge.getStyle().set("color", DEFAULT_UNREAD_BADGE_COLOR); + } + } + + /** + * Sets the display mode programmatically. In {@link ChatAssistantMode#MOBILE} mode the chat window + * opens as a full-screen dialog and the FAB is not movable (dragging would compete with touch + * scrolling, and the full-screen dialog already covers the viewport); the desktop movable preference + * is preserved and restored when switching back. In {@link ChatAssistantMode#DESKTOP} mode the window + * opens as an anchored popover. + *

+ * When automatic switching is enabled (see {@link #setMobileModeSwitchingEnabled(boolean)}), this + * value may be overridden the next time the viewport crosses the configured breakpoint. To keep + * full manual control, disable automatic switching first. + * + * @param mode the mode to switch to, it cannot be null + */ + public void setMode(ChatAssistantMode mode) { + Objects.requireNonNull(mode, "Mode cannot be null"); + setMode(mode, false); + } + + /** Returns the current display mode. */ + public ChatAssistantMode getMode() { + return mode; + } + + /** + * Applies the given mode, reconciling the active surface and open state, and fires a + * {@link ModeChangedEvent} when the mode actually changes. + * + * @param mode the mode to switch to + * @param fromClient whether the change originated from a client-side breakpoint crossing + */ + protected void setMode(ChatAssistantMode mode, boolean fromClient) { + if (this.mode == mode) { + return; + } + boolean mobile = mode == ChatAssistantMode.MOBILE; + // Capture the open state before switching so it can be carried over to the other surface. + boolean wasOpened = isOpened(); + this.mode = mode; + this.container.setPadding(!mobile); + if (mobile) { + if (overlay.getChildren().anyMatch(child -> child == container)) { + overlay.remove(container); + mobileChatWindow.add(container); + } + // Preserve the user's movable preference; mobile mode always disables dragging. + boolean movablePreference = this.fabMovable; + setFabMovable(false); + this.fabMovable = movablePreference; + // The popover would otherwise stay open with no content; move the open state to the dialog. + if (wasOpened) { + chatWindow.close(); + mobileChatWindow.open(); + } + } else { + if (mobileChatWindow.getChildren().anyMatch(child -> child == container)) { + mobileChatWindow.remove(container); + overlay.add(container); + } + setFabMovable(this.fabMovable); + // Move the open state from the dialog back to the popover. + if (wasOpened) { + mobileChatWindow.close(); + chatWindow.open(); + } + } + // Return the FAB to its configured corner; its dragged position is not meaningful across modes. + resetFabPosition(); + fireEvent(new ModeChangedEvent(this, fromClient, this.mode)); + } + + /** + * Sets the mobile mode programmatically. Convenience wrapper over {@link #setMode(ChatAssistantMode)}. + * + * @param mobileMode {@code true} for {@link ChatAssistantMode#MOBILE}, {@code false} for + * {@link ChatAssistantMode#DESKTOP} + */ + public void setMobileMode(boolean mobileMode) { + setMode(mobileMode ? ChatAssistantMode.MOBILE : ChatAssistantMode.DESKTOP); + } + + /** Returns true if the component is currently in {@link ChatAssistantMode#MOBILE} mode. */ + public boolean isMobileMode() { + return isMobile(); + } + + /** + * Adds a listener that is notified whenever the component switches between + * {@link ChatAssistantMode#MOBILE} and {@link ChatAssistantMode#DESKTOP} mode. + * + * @param listener the listener to add; it receives the mode the component switched to + * @return a registration for removing the listener + */ + public Registration addModeChangedListener(ComponentEventListener listener) { + return addListener(ModeChangedEvent.class, listener); + } + + /** Event fired when the chat assistant switches between mobile and desktop mode. */ + public static class ModeChangedEvent extends ComponentEvent> { + + private final ChatAssistantMode mode; + + protected ModeChangedEvent(ChatAssistant source, boolean fromClient, ChatAssistantMode mode) { + super(source, fromClient); + this.mode = mode; + } + + /** Returns the mode the component switched to. */ + public ChatAssistantMode getMode() { + return mode; + } + } + + /** + * Adds a listener that is notified when the chat window's own size crosses the given threshold. At + * least one of {@code width}/{@code height} must be non-null; a {@code null} axis is not tracked. + * When both are given, the listener fires only when both are simultaneously satisfied (AND). + *

+ * The chat window only has a size while it is open, so the listener observes size changes (drag + * resize, {@link #setWindowWidth}/{@link #setWindowHeight}, or viewport clamping) while open. On each + * open it is invoked once with the current state, then only when the size crosses the threshold. The + * threshold is inclusive: a window exactly at the threshold counts as above it + * ({@link ScreenSizeEvent#isAboveThreshold()} is {@code true}). Each listener only receives events + * for its own threshold. + * + * @param width the width threshold in pixels, or {@code null} to ignore width + * @param height the height threshold in pixels, or {@code null} to ignore height + * @param listener the listener to add + * @return a registration for removing the listener + */ + public Registration addScreenSizeListener(Integer width, Integer height, + ComponentEventListener listener) { + Objects.requireNonNull(listener, "Listener cannot be null"); + if (width == null && height == null) { + throw new IllegalArgumentException("At least one of width or height must be provided"); + } + if ((width != null && width <= 0) || (height != null && height <= 0)) { + throw new IllegalArgumentException("Thresholds must be greater than 0"); + } + + int key = ++screenSizeKeySeq; + screenSizeListeners.put(key, new ScreenSizeListenerEntry(width, height, listener)); + if (this.getUI().isPresent()) { + applyScreenSizeListener(key); + } + + return () -> { + screenSizeListeners.remove(key); + this.getElement().executeJs("window.fcChatAssistantScreenSizeOff?.($0, $1);", this.getElement(), key); + }; + } + + /** + * (Re)registers a single chat-window size observer on the client. Called on attach and on each + * popover open (the overlay content is rebuilt each open); the JS replaces any existing observer for + * the key and re-delivers the current state, so it is safe to call repeatedly. + */ + private void applyScreenSizeListener(Integer key) { + ScreenSizeListenerEntry entry = screenSizeListeners.get(key); + if (entry == null) { + return; + } + this.getElement().executeJs( + "window.fcChatAssistantScreenSize($0, $1, $2, $3, $4);", + this.getElement(), overlay, key, entry.width, entry.height); + } + + /** Per-key bookkeeping for a screen-size listener: its thresholds and the listener to invoke. */ + private static final class ScreenSizeListenerEntry implements Serializable { + private final Integer width; + private final Integer height; + private final ComponentEventListener listener; + + ScreenSizeListenerEntry(Integer width, Integer height, + ComponentEventListener listener) { + this.width = width; + this.height = height; + this.listener = listener; + } + } + + /** Direction in which the chat window crossed a configured threshold. */ + public enum ScreenSizeDirection { + /** The chat window grew to or past the threshold (it is now at or above it). */ + INCREASED, + /** The chat window shrank below the threshold (it is now under it). */ + DECREASED + } + + /** + * Event fired when the chat window's size crosses a width and/or height threshold registered through + * {@link #addScreenSizeListener(Integer, Integer, ComponentEventListener)}. It reports the crossing + * direction and the configured threshold(s); no live size is exposed. + */ + public static class ScreenSizeEvent extends ComponentEvent> { + + private final Integer widthThreshold; + private final Integer heightThreshold; + private final boolean aboveThreshold; + + protected ScreenSizeEvent(ChatAssistant source, boolean fromClient, Integer widthThreshold, + Integer heightThreshold, boolean aboveThreshold) { + super(source, fromClient); + this.widthThreshold = widthThreshold; + this.heightThreshold = heightThreshold; + this.aboveThreshold = aboveThreshold; + } + + /** The configured width threshold in pixels, or {@code null} if width was not tracked. */ + public Integer getWidthThreshold() { + return widthThreshold; + } + + /** The configured height threshold in pixels, or {@code null} if height was not tracked. */ + public Integer getHeightThreshold() { + return heightThreshold; + } + + /** + * Returns {@code true} when the chat window is now at or above the threshold (it increased past + * it), {@code false} when it is now below (it decreased under it). + */ + public boolean isAboveThreshold() { + return aboveThreshold; + } + + /** Convenience view of {@link #isAboveThreshold()} as a direction. */ + public ScreenSizeDirection getDirection() { + return aboveThreshold ? ScreenSizeDirection.INCREASED : ScreenSizeDirection.DECREASED; + } + } + + /** + * Returns the maximum screen width, in pixels, below which mobile mode is activated automatically. + * + * @return the breakpoint in pixels + */ + public int getMobileBreakpoint() { + return mobileBreakpoint; + } + + /** + * Enables or disables automatic switching between mobile and desktop mode based on the configured + * breakpoint. Automatic switching is disabled by default; it is enabled either by defining a + * breakpoint in the constructor or by calling this method with {@code true}. Enable it only after + * preparing the mobile experience (e.g. providing a way to close the full-screen dialog). + *

+ * When disabled, the component is left in whatever mode it is currently in (freeze), and the mode + * can only be changed manually via {@link #setMode(ChatAssistantMode)}. When enabled, the + * breakpoint is evaluated against the current viewport width. If no breakpoint was configured, the + * default ({@value #DEFAULT_MOBILE_BREAKPOINT}px) is used. + * + * @param enabled {@code true} to switch automatically on viewport changes, {@code false} to freeze + */ + public void setMobileModeSwitchingEnabled(boolean enabled) { + if (this.mobileModeSwitchingEnabled == enabled) { + return; + } + this.mobileModeSwitchingEnabled = enabled; + if (enabled) { + this.getElement().executeJs( + "window.fcChatAssistantMobileMode($0, $1);", this.getElement(), this.mobileBreakpoint); + } else { + this.getElement().executeJs("window.fcChatAssistantMobileModeOff($0);", this.getElement()); + } + } + + /** + * Returns whether automatic switching between mobile and desktop mode is enabled. + * + * @return {@code true} if automatic switching is enabled + */ + public boolean isMobileModeSwitchingEnabled() { + return mobileModeSwitchingEnabled; + } } From 3e64655662bae21417244220a6ae9deecd3a2032 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:03 -0300 Subject: [PATCH 09/25] feat(demo): rewrite core and content demos Commented basic/lazy-loading/markdown/generative demos using the current API. --- .../chatassistant/ChatAssistantDemo.java | 104 +++++--- .../ChatAssistantGenerativeDemo.java | 149 ++++++----- .../ChatAssistantLazyLoadingDemo.java | 235 ++++++++++-------- .../ChatAssistantMarkdownDemo.java | 43 +++- 4 files changed, 309 insertions(+), 222 deletions(-) diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java index 076ca59..32056c0 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java @@ -7,9 +7,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -23,11 +23,9 @@ import com.flowingcode.vaadin.addons.demo.SourcePosition; import com.google.common.base.Strings; import com.vaadin.flow.component.UI; -import com.vaadin.flow.component.avatar.Avatar; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.dependency.CssImport; -import com.vaadin.flow.component.html.Image; -import com.vaadin.flow.component.icon.SvgIcon; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.data.renderer.ComponentRenderer; @@ -35,60 +33,69 @@ import com.vaadin.flow.router.Route; import java.time.LocalDateTime; import java.util.Timer; +import java.util.TimerTask; @DemoSource(sourcePosition = SourcePosition.PRIMARY) @PageTitle("Basic Demo") -@SuppressWarnings("serial") @Route(value = "chat-assistant/basic-demo", layout = ChatAssistantDemoView.class) @CssImport("./styles/chat-assistant-styles-demo.css") public class ChatAssistantDemo extends VerticalLayout { - + public ChatAssistantDemo() { + // Create the assistant with all defaults ChatAssistant chatAssistant = new ChatAssistant<>(); - SvgIcon icon = new SvgIcon("chatbot.svg"); - icon.setColor("var(--lumo-primary-contrast-color)"); - chatAssistant.setFabIcon(icon); + // Set a sensible default window size chatAssistant.setWindowWidth("400px"); chatAssistant.setWindowHeight("400px"); + + // Render each message with a custom component that also shows its tagline. + chatAssistant.setMessagesRenderer(new ComponentRenderer( + CustomChatMessage::new, + (component, message) -> { + ((CustomChatMessage) component).setMessage(message); + return component; + })); + + // Echo messages submitted by the user from the chat input. + chatAssistant.setSubmitListener(se -> chatAssistant.sendMessage(CustomMessage.builder() + .messageTime(LocalDateTime.now()).name("User").content(se.getValue()) + .tagline("Generated by user").build())); + + // Text area used to compose the assistant's answer. TextArea message = new TextArea(); message.setLabel("Enter a message from the assistant"); message.setSizeFull(); - message.addKeyPressListener(ev->{ + message.addKeyPressListener(ev -> { if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); } }); - message.addBlurListener(ev->chatAssistant.clearWhoIsTyping()); - chatAssistant.setMessagesRenderer(new ComponentRenderer(m -> { - return new CustomChatMessage(m); - }, - (component, m) -> { - ((CustomChatMessage) component).setMessage(m); - return component; - })); - chatAssistant.setSubmitListener(se -> { - chatAssistant.sendMessage(CustomMessage.builder().messageTime(LocalDateTime.now()) - .name("User").content(se.getValue()).tagline("Generated by user").build()); - }); + message.addBlurListener(ev -> chatAssistant.clearWhoIsTyping()); + // Send the composed text as an assistant message. Button chat = new Button("Chat"); chat.addClickListener(ev -> { - CustomMessage m = CustomMessage.builder().content(message.getValue()).messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").tagline("Generated by assistant").build(); - - chatAssistant.sendMessage(m); + chatAssistant.sendMessage(CustomMessage.builder().content(message.getValue()) + .messageTime(LocalDateTime.now()).name("Assistant").avatar("chatbot.png") + .tagline("Generated by assistant").build()); message.clear(); }); + + // Send a message in a loading state, then resolve it after 5 seconds. Button chatWithThinking = new Button("Chat With Thinking"); chatWithThinking.addClickListener(ev -> { - CustomMessage delayedMessage = CustomMessage.builder().loading(true).content(message.getValue()) - .messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").tagline("Generated by assistant").build(); - - UI currentUI = UI.getCurrent(); + CustomMessage delayedMessage = CustomMessage.builder() + .loading(true) + .content(message.getValue()) + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build(); chatAssistant.sendMessage(delayedMessage); - new Timer().schedule(new java.util.TimerTask() { + UI currentUI = UI.getCurrent(); + new Timer().schedule(new TimerTask() { @Override public void run() { currentUI.access(() -> { @@ -97,15 +104,36 @@ public void run() { }); } }, 5000); - message.clear(); }); - chatAssistant.sendMessage(CustomMessage.builder().content("Hello, I am here to assist you") + + Button unread = new Button("Toggle unread badge"); + unread.addClickListener(ev -> { + if (chatAssistant.getUnreadMessages() > 0) { + chatAssistant.setUnreadMessages(0); + } else { + chatAssistant.setUnreadMessages((int) (Math.random() * 98) + 1); + } + }); + + // Seed the conversation + chatAssistant.sendMessage( + CustomMessage.builder() + .content("Hello, I am here to assist you") .messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").tagline("Generated by assistant").build()); + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant").build() + ); + + HorizontalLayout row = new HorizontalLayout(chat, chatWithThinking, unread); + row.setSpacing(true); + row.setPadding(false); + row.getStyle().set("flex-wrap", "wrap"); - chatAssistant.setUnreadMessages(4); + unread.click(); + chatAssistant.setOpened(true); - add(message, chat, chatWithThinking, chatAssistant); + add(message, row, chatAssistant); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java index f8afa9e..b1cb3ae 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java @@ -7,9 +7,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -23,11 +23,10 @@ import com.flowingcode.vaadin.addons.demo.SourcePosition; import com.google.common.base.Strings; import com.vaadin.flow.component.UI; -import com.vaadin.flow.component.avatar.Avatar; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.dependency.CssImport; -import com.vaadin.flow.component.html.Image; import com.vaadin.flow.component.icon.SvgIcon; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.data.renderer.ComponentRenderer; @@ -42,108 +41,130 @@ @DemoSource(sourcePosition = SourcePosition.PRIMARY) @PageTitle("Generative Answer Demo") -@SuppressWarnings("serial") @Route(value = "chat-assistant/generative-demo", layout = ChatAssistantDemoView.class) @CssImport("./styles/chat-assistant-styles-demo.css") public class ChatAssistantGenerativeDemo extends VerticalLayout { - + public ChatAssistantGenerativeDemo() { String sampleText = "Hi, I'm an advanced language model. I'm here to help you demonstrate" + " how a text-streaming chat component works in Vaadin. As you can see, each word appears" + " with a slight pause, simulating the time it would take me to \"think\" and generate" + " the next word. I hope this is useful for your demonstration!"; - + + // Create the assistant with all defaults and give it a custom FAB icon. ChatAssistant chatAssistant = new ChatAssistant<>(); SvgIcon icon = new SvgIcon("chatbot.svg"); icon.setColor("var(--lumo-primary-contrast-color)"); chatAssistant.setFabIcon(icon); chatAssistant.setWindowWidth("400px"); chatAssistant.setWindowHeight("400px"); + + // Render each message with a custom component that also shows its tagline. + chatAssistant.setMessagesRenderer(new ComponentRenderer( + CustomChatMessage::new, + (component, message) -> { + ((CustomChatMessage) component).setMessage(message); + return component; + }) + ); + + // Echo messages submitted by the user from the chat input. + chatAssistant.setSubmitListener(se -> chatAssistant.sendMessage( + CustomMessage.builder() + .messageTime(LocalDateTime.now()) + .name("User") + .content(se.getValue()) + .tagline("Generated by user") + .build() + )); + + // Text area pre-filled with the answer that will be streamed. TextArea message = new TextArea(); message.setLabel("Enter a message from the assistant"); message.setSizeFull(); message.setValue(sampleText); - message.addKeyPressListener(ev->{ + message.addKeyPressListener(ev -> { if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); } }); - message.addBlurListener(ev->chatAssistant.clearWhoIsTyping()); - chatAssistant.setMessagesRenderer(new ComponentRenderer(m -> { - return new CustomChatMessage(m); - }, - (component, m) -> { - ((CustomChatMessage) component).setMessage(m); - return component; - })); - chatAssistant.setSubmitListener(se -> { - chatAssistant.sendMessage(CustomMessage.builder().messageTime(LocalDateTime.now()) - .name("User").content(se.getValue()).tagline("Generated by user").build()); - }); + message.addBlurListener(ev -> chatAssistant.clearWhoIsTyping()); + // Send the composed text as a single assistant message. Button chat = new Button("Chat"); chat.addClickListener(ev -> { - CustomMessage m = CustomMessage.builder().content(message.getValue()).messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").tagline("Generated by assistant").build(); - - chatAssistant.sendMessage(m); + chatAssistant.sendMessage( + CustomMessage.builder() + .content(message.getValue()) + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build() + ); message.clear(); }); + + // Send an empty loading message and stream the answer into it word by word. Button chatWithThinking = new Button("Chat With Generative Thinking"); chatWithThinking.addClickListener(ev -> { - String messageToSend = message.getValue(); - CustomMessage delayedMessage = CustomMessage.builder().loading(true).content("") - .messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").tagline("Generated by assistant").build(); - - UI currentUI = UI.getCurrent(); + String answer = message.getValue(); + CustomMessage delayedMessage = CustomMessage.builder() + .loading(true) + .content("") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build(); chatAssistant.sendMessage(delayedMessage); + UI currentUI = UI.getCurrent(); CompletableFuture.runAsync(() -> { try { TimeUnit.MILLISECONDS.sleep(500); - currentUI.access(() -> { - delayedMessage.setLoading(false); - }); - streamWords(messageToSend) - .forEach(item -> { - currentUI.access(() -> { - delayedMessage.setContent(delayedMessage.getContent() + item); - chatAssistant.updateMessage(delayedMessage); - }); - }); + currentUI.access(() -> delayedMessage.setLoading(false)); + streamWords(answer).forEach(word -> currentUI.access(() -> { + delayedMessage.setContent(delayedMessage.getContent() + word); + chatAssistant.updateMessage(delayedMessage); + })); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); - message.clear(); }); - chatAssistant.sendMessage(CustomMessage.builder().content("Hello, I am here to assist you") + + // Seed the conversation with a greeting and open the window. + chatAssistant.sendMessage( + CustomMessage.builder() + .content("Hello, I am here to assist you") .messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").tagline("Generated by assistant").build()); - - add(message, chat, chatWithThinking, chatAssistant); + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build() + ); + chatAssistant.setOpened(true); + + HorizontalLayout row = new HorizontalLayout(chat, chatWithThinking); + row.getStyle().set("flex-wrap", "wrap"); + + add(message, row, chatAssistant); } - - public Stream streamWords(String fullText) { + + /** Splits the text into words and emits them one at a time with a small random delay. */ + private Stream streamWords(String fullText) { if (fullText == null || fullText.isEmpty()) { - return Stream.empty(); + return Stream.empty(); } - - String[] words = fullText.split("\\s+"); - - return Arrays.stream(words) - .map(word -> { - try { - long delay = ThreadLocalRandom.current().nextLong(50, 250); - Thread.sleep(delay); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - - return word + " "; - }); -} - + return Arrays.stream(fullText.split("\\s+")).map(word -> { + try { + Thread.sleep(ThreadLocalRandom.current().nextLong(50, 250)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return word + " "; + }); + } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java index 1d2c639..de0438e 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java @@ -7,9 +7,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -42,88 +42,15 @@ @DemoSource(sourcePosition = SourcePosition.PRIMARY) @PageTitle("Lazy Loading Demo") -@SuppressWarnings("serial") @Route(value = "chat-assistant/lazy-loading-demo", layout = ChatAssistantDemoView.class) @CssImport("./styles/chat-assistant-styles-demo.css") public class ChatAssistantLazyLoadingDemo extends VerticalLayout { - - List messages = new ArrayList<>(Arrays.asList( - Message.builder().name("Claudius").content("I have sent to seek him and to find the body.\n" - + "How dangerous is it that this man goes loose!\n" - + "Yet must not we put the strong law on him.\n" - + "He's loved of the distracted multitude,\n" - + "Who like not in their judgment, but their eyes.\n" - + "And where 'tis so, th' offender's scourge is weighed,\n" - + "But never the offense. To bear all smooth and even,\n" - + "This sudden sending him away must seem\n" - + "Deliberate pause. Diseases desperate grown\n" - + "By desperate appliance are relieved,\n" - + "Or not at all.").build(), - Message.builder().name("Rosencrantz").content("(Enter)").build(), - Message.builder().name("Claudius").content("How now, what hath befall'n?").build(), - Message.builder().name("Rosencrantz").content("Where the dead body is bestowed, my lord,\n" - + "We cannot get from him.").build(), - Message.builder().name("Claudius").content("But where is he?").build(), - Message.builder().name("Rosencrantz").content("Without, my lord; guarded, to know your pleasure.").build(), - Message.builder().name("Claudius").content("Bring him before us.").build(), - Message.builder().name("Rosencrantz").content("Ho, Guildenstern! Bring in my lord.").build(), - Message.builder().name("Claudius").content("Now, Hamlet, where's Polonius?").build(), - Message.builder().name("Hamlet").content("At supper.").build(), - Message.builder().name("Claudius").content("At supper? Where? ").build(), - Message.builder().name("Hamlet").content("Not where he eats, but where he is eaten. A certain \n" - + "convocation of politic worms are e'en at him. Your worm is your \n" - + "only emperor for diet. We fat all creatures else to fat us, and \n" - + "we fat ourselves for maggots. Your fat king and your lean beggar \n" - + "is but variable service- two dishes, but to one table. That's the \n" - + "end.").build(), - Message.builder().name("Claudius").content("Alas, alas!").build(), - Message.builder().name("Hamlet").content("A man may fish with the worm that hath eat of a king, and eat \n" - + "of the fish that hath fed of that worm.").build(), - Message.builder().name("Claudius").content("What dost thou mean by this?").build(), - Message.builder().name("Hamlet").content("Nothing but to show you how a king may go a progress through \n" - + "the guts of a beggar.\n" - + "").build(), - Message.builder().name("Claudius").content("Where is Polonius?").build(), - Message.builder().name("Hamlet").content("In heaven. Send thither to see. If your messenger find him not \n" - + "there, seek him i' th' other place yourself. But indeed, if you\n" - + "find him not within this month, you shall nose him as you go up \n" - + "the stair, into the lobby.").build(), - Message.builder().name("Claudius").content("Go seek him there.").build(), - Message.builder().name("Hamlet").content("He will stay till you come.").build(), - Message.builder().name("Claudius").content("Hamlet, this deed, for thine especial safety,- \n" - + "Which we do tender as we dearly grieve \n" - + "For that which thou hast done,- must send thee hence \n" - + "With fiery quickness. Therefore prepare thyself. \n" - + "The bark is ready and the wind at help, \n" - + "Th' associates tend, and everything is bent \n" - + "For England.").build(), - Message.builder().name("Hamlet").content("For England?").build(), - Message.builder().name("Claudius").content("Ay, Hamlet.").build(), - Message.builder().name("Hamlet").content("Good.").build(), - Message.builder().name("Claudius").content("So is it, if thou knew'st our purposes.").build(), - Message.builder().name("Hamlet").content("I see a cherub that sees them. But come, for England! \n" - + "Farewell, dear mother.").build(), - Message.builder().name("Claudius").content("Thy loving father, Hamlet.").build(), - Message.builder().name("Hamlet").content("My mother! Father and mother is man and wife; man and wife is\n" - + "one flesh; and so, my mother. Come, for England!").build(), - Message.builder().name("Claudius").content("Follow him at foot; tempt him with speed aboard. \n" - + "Delay it not; I'll have him hence to-night. \n" - + "Away! for everything is seal'd and done\n" - + "That else leans on th' affair. Pray you make haste.").build(), - Message.builder().name("Claudius").content("And, England, if my love thou hold'st at aught,- \n" - + "As my great power thereof may give thee sense, \n" - + "Since yet thy cicatrice looks raw and red\n" - + "After the Danish sword, and thy free awe \n" - + "Pays homage to us,- thou mayst not coldly set \n" - + "Our sovereign process, which imports at full, \n" - + "By letters congruing to that effect, \n" - + "The present death of Hamlet. Do it, England; \n" - + "For like the hectic in my blood he rages, \n" - + "And thou must cure me. Till I know 'tis done, \n" - + "Howe'er my haps, my joys were ne'er begun. ").build() - )); - + public ChatAssistantLazyLoadingDemo() { + // Backing message list, served lazily by the data provider below. + List messages = createMessages(); + + // Create the assistant; the "small" class shrinks the message font (see demo CSS). ChatAssistant chatAssistant = new ChatAssistant<>(); chatAssistant.setClassName("small"); SvgIcon icon = new SvgIcon("chatbot.svg"); @@ -131,51 +58,143 @@ public ChatAssistantLazyLoadingDemo() { chatAssistant.setFabIcon(icon); chatAssistant.setWindowWidth("400px"); chatAssistant.setWindowHeight("400px"); + + // Feed messages through a lazy DataProvider, reporting the requested page as it loads. Span lazyLoadingData = new Span(); - DataProvider dataProvider = DataProvider.fromCallbacks(query->{ - lazyLoadingData.setText("Loading messages from: " + query.getOffset() + ", with limit: " + query.getLimit()); + DataProvider dataProvider = DataProvider.fromCallbacks(query -> { + lazyLoadingData.setText( + "Loading messages from: " + query.getOffset() + ", with limit: " + query.getLimit() + ); return messages.stream().skip(query.getOffset()).limit(query.getLimit()); - }, query->{ - return messages.size(); - }); + }, query -> messages.size()); chatAssistant.setDataProvider(dataProvider); - + + // Replace the default header with a custom title bar and minimize button. + Icon minimize = VaadinIcon.MINUS.create(); + minimize.addClickListener(ev -> chatAssistant.setOpened(!chatAssistant.isOpened())); + Span title = new Span("Customized Assistant Header"); + title.setWidthFull(); + HorizontalLayout headerBar = new HorizontalLayout(title, minimize); + headerBar.setWidthFull(); + chatAssistant.setHeaderComponent(headerBar); + + // Text area used to compose the assistant's answer. TextArea message = new TextArea(); message.setLabel("Enter a message from the assistant"); message.setSizeFull(); - message.addKeyPressListener(ev->{ + message.addKeyPressListener(ev -> { if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); } }); - message.addBlurListener(ev->chatAssistant.clearWhoIsTyping()); + message.addBlurListener(ev -> chatAssistant.clearWhoIsTyping()); + // With a custom DataProvider, append to the list and refresh instead of using sendMessage. Button chat = new Button("Chat"); chat.addClickListener(ev -> { - Message m = Message.builder().content(message.getValue()).messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").build(); - - messages.add(m); - dataProvider.refreshAll(); - chatAssistant.scrollToEnd(); - message.clear(); + messages.add(Message.builder() + .content(message.getValue()) + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build()); + dataProvider.refreshAll(); + chatAssistant.scrollToEnd(); + message.clear(); }); + + // Messages typed in the chat input are appended the same way. chatAssistant.setSubmitListener(ev -> { - Message userMessage = Message.builder().messageTime(LocalDateTime.now()) - .name("User").content(ev.getValue()).build(); - messages.add(userMessage); - dataProvider.refreshAll(); - chatAssistant.scrollToEnd(); + messages.add(Message.builder() + .messageTime(LocalDateTime.now()) + .name("User") + .content(ev.getValue()) + .build() + ); + dataProvider.refreshAll(); + chatAssistant.scrollToEnd(); }); - Icon minimize = VaadinIcon.MINUS.create(); - minimize.addClickListener(ev -> chatAssistant.setOpened(!chatAssistant.isOpened())); - Span title = new Span("Customized Assistant Header"); - title.setWidthFull(); - HorizontalLayout headerBar = new HorizontalLayout(title, minimize); - headerBar.setWidthFull(); - chatAssistant.setHeaderComponent(headerBar); + + chatAssistant.setOpened(true); add(message, lazyLoadingData, chat, chatAssistant); } - + + /** Sample conversation (an excerpt from Hamlet) used to populate the lazy data provider. */ + private static List createMessages() { + return new ArrayList<>(Arrays.asList( + Message.builder().name("Claudius").content("I have sent to seek him and to find the body.\n" + + "How dangerous is it that this man goes loose!\n" + + "Yet must not we put the strong law on him.\n" + + "He's loved of the distracted multitude,\n" + + "Who like not in their judgment, but their eyes.\n" + + "And where 'tis so, th' offender's scourge is weighed,\n" + + "But never the offense. To bear all smooth and even,\n" + + "This sudden sending him away must seem\n" + + "Deliberate pause. Diseases desperate grown\n" + + "By desperate appliance are relieved,\n" + + "Or not at all.").build(), + Message.builder().name("Rosencrantz").content("(Enter)").build(), + Message.builder().name("Claudius").content("How now, what hath befall'n?").build(), + Message.builder().name("Rosencrantz").content("Where the dead body is bestowed, my lord,\n" + + "We cannot get from him.").build(), + Message.builder().name("Claudius").content("But where is he?").build(), + Message.builder().name("Rosencrantz").content("Without, my lord; guarded, to know your pleasure.").build(), + Message.builder().name("Claudius").content("Bring him before us.").build(), + Message.builder().name("Rosencrantz").content("Ho, Guildenstern! Bring in my lord.").build(), + Message.builder().name("Claudius").content("Now, Hamlet, where's Polonius?").build(), + Message.builder().name("Hamlet").content("At supper.").build(), + Message.builder().name("Claudius").content("At supper? Where? ").build(), + Message.builder().name("Hamlet").content("Not where he eats, but where he is eaten. A certain \n" + + "convocation of politic worms are e'en at him. Your worm is your \n" + + "only emperor for diet. We fat all creatures else to fat us, and \n" + + "we fat ourselves for maggots. Your fat king and your lean beggar \n" + + "is but variable service- two dishes, but to one table. That's the \n" + + "end.").build(), + Message.builder().name("Claudius").content("Alas, alas!").build(), + Message.builder().name("Hamlet").content("A man may fish with the worm that hath eat of a king, and eat \n" + + "of the fish that hath fed of that worm.").build(), + Message.builder().name("Claudius").content("What dost thou mean by this?").build(), + Message.builder().name("Hamlet").content("Nothing but to show you how a king may go a progress through \n" + + "the guts of a beggar.\n" + + "").build(), + Message.builder().name("Claudius").content("Where is Polonius?").build(), + Message.builder().name("Hamlet").content("In heaven. Send thither to see. If your messenger find him not \n" + + "there, seek him i' th' other place yourself. But indeed, if you\n" + + "find him not within this month, you shall nose him as you go up \n" + + "the stair, into the lobby.").build(), + Message.builder().name("Claudius").content("Go seek him there.").build(), + Message.builder().name("Hamlet").content("He will stay till you come.").build(), + Message.builder().name("Claudius").content("Hamlet, this deed, for thine especial safety,- \n" + + "Which we do tender as we dearly grieve \n" + + "For that which thou hast done,- must send thee hence \n" + + "With fiery quickness. Therefore prepare thyself. \n" + + "The bark is ready and the wind at help, \n" + + "Th' associates tend, and everything is bent \n" + + "For England.").build(), + Message.builder().name("Hamlet").content("For England?").build(), + Message.builder().name("Claudius").content("Ay, Hamlet.").build(), + Message.builder().name("Hamlet").content("Good.").build(), + Message.builder().name("Claudius").content("So is it, if thou knew'st our purposes.").build(), + Message.builder().name("Hamlet").content("I see a cherub that sees them. But come, for England! \n" + + "Farewell, dear mother.").build(), + Message.builder().name("Claudius").content("Thy loving father, Hamlet.").build(), + Message.builder().name("Hamlet").content("My mother! Father and mother is man and wife; man and wife is\n" + + "one flesh; and so, my mother. Come, for England!").build(), + Message.builder().name("Claudius").content("Follow him at foot; tempt him with speed aboard. \n" + + "Delay it not; I'll have him hence to-night. \n" + + "Away! for everything is seal'd and done\n" + + "That else leans on th' affair. Pray you make haste.").build(), + Message.builder().name("Claudius").content("And, England, if my love thou hold'st at aught,- \n" + + "As my great power thereof may give thee sense, \n" + + "Since yet thy cicatrice looks raw and red\n" + + "After the Danish sword, and thy free awe \n" + + "Pays homage to us,- thou mayst not coldly set \n" + + "Our sovereign process, which imports at full, \n" + + "By letters congruing to that effect, \n" + + "The present death of Hamlet. Do it, England; \n" + + "For like the hectic in my blood he rages, \n" + + "And thou must cure me. Till I know 'tis done, \n" + + "Howe'er my haps, my joys were ne'er begun. ").build())); + } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java index 2a665b9..0411b8c 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java @@ -7,9 +7,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -34,39 +34,58 @@ @DemoSource(sourcePosition = SourcePosition.PRIMARY) @PageTitle("Markdown Demo") -@SuppressWarnings("serial") @Route(value = "chat-assistant/markdown-demo", layout = ChatAssistantDemoView.class) @CssImport("./styles/chat-assistant-styles-demo.css") public class ChatAssistantMarkdownDemo extends VerticalLayout { - + public ChatAssistantMarkdownDemo() { + // Pass true to the constructor to render message content as Markdown. ChatAssistant chatAssistant = new ChatAssistant<>(true); SvgIcon icon = new SvgIcon("chatbot.svg"); icon.setColor("var(--lumo-primary-contrast-color)"); chatAssistant.setFabIcon(icon); chatAssistant.setWindowWidth("400px"); chatAssistant.setWindowHeight("400px"); + + // Text area used to compose the assistant's answer, prefilled with a Markdown sample to send. TextArea message = new TextArea(); message.setLabel("Enter a message from the assistant (try using Markdown)"); message.setSizeFull(); - message.addKeyPressListener(ev->{ + message.setValue("# Heading\n\n" + + "Some **bold** and *italic* text, with `inline code`.\n\n" + + "- First item\n" + + "- Second item\n"); + message.addKeyPressListener(ev -> { if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); } }); - message.addBlurListener(ev->chatAssistant.clearWhoIsTyping()); + message.addBlurListener(ev -> chatAssistant.clearWhoIsTyping()); + // Send the composed Markdown as an assistant message. Button chat = new Button("Chat"); chat.addClickListener(ev -> { - Message m = Message.builder().content(message.getValue()).messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").build(); - - chatAssistant.sendMessage(m); + chatAssistant.sendMessage( + Message.builder() + .content(message.getValue()) + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build() + ); message.clear(); }); - chatAssistant.sendMessage(Message.builder().content("**Hello, I am here to assist you**") + + // Seed the conversation with a Markdown-formatted greeting and open the window. + chatAssistant.sendMessage( + Message.builder() + .content("**Hello, I am here to assist you**") .messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").build()); + .name("Assistant") + .avatar("chatbot.png") + .build() + ); + chatAssistant.setOpened(true); add(message, chat, chatAssistant); } From 2778d5258b2693a1ef742daf9fb660e60e51b66f Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:03 -0300 Subject: [PATCH 10/25] feat(demo): add FAB configuration and in-a-box demos A FAB configuration demo (custom icon, size and color variants, section titles, movable/resizable/indicator toggles with notifications) and an in-a-box demo with a container-anchored FAB positioned via setFabPosition. --- .../chatassistant/ChatAssistantBoxDemo.java | 87 +++++++++++++ .../ChatAssistantFabConfigDemo.java | 121 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java create mode 100644 src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java new file mode 100644 index 0000000..abd24fb --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java @@ -0,0 +1,87 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2025 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.chatassistant; + +import com.flowingcode.vaadin.addons.chatassistant.model.FabPosition; +import com.flowingcode.vaadin.addons.chatassistant.model.Message; +import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.flowingcode.vaadin.addons.demo.SourcePosition; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.dependency.CssImport; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.icon.SvgIcon; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import java.time.LocalDateTime; + +@DemoSource(sourcePosition = SourcePosition.PRIMARY) +@PageTitle("FAB In A Box Demo") +@Route(value = "chat-assistant/box-demo", layout = ChatAssistantDemoView.class) +@CssImport("./styles/chat-assistant-styles-demo.css") +public class ChatAssistantBoxDemo extends VerticalLayout { + + public ChatAssistantBoxDemo() { + SvgIcon icon = new SvgIcon("chatbot.svg"); + icon.setColor("var(--lumo-primary-contrast-color)"); + + // With fabAnchoredToViewport(false) the FAB is positioned relative to its container instead of + // the viewport, so it lives inside the box below rather than floating over the whole screen. + ChatAssistant chatAssistant = ChatAssistant.builder() + .fabIcon(icon) + .fabAnchoredToViewport(false) + .build(); + chatAssistant.setWindowWidth("400px"); + chatAssistant.setWindowHeight("400px"); + + // Seed the conversation with a greeting. + chatAssistant.sendMessage( + Message.builder() + .content("Use the buttons to move me around the box.") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build() + ); + + // Move the FAB to each corner of the box. + HorizontalLayout controls = new HorizontalLayout( + new Button("Top left", ev -> chatAssistant.setFabPosition(FabPosition.TOP_LEFT)), + new Button("Top right", ev -> chatAssistant.setFabPosition(FabPosition.TOP_RIGHT)), + new Button("Bottom left", ev -> chatAssistant.setFabPosition(FabPosition.BOTTOM_LEFT)), + new Button("Bottom right", ev -> chatAssistant.setFabPosition(FabPosition.BOTTOM_RIGHT)) + ); + controls.getStyle().set("flex-wrap", "wrap"); + + // A visible, relatively-positioned box that hosts the non-fixed FAB. + Span description = new Span("The FAB is not anchored to the viewport but positioned relative to this box. This behaviour disables dragging."); + Div box = new Div(chatAssistant); + box.getStyle() + .set("position", "relative") + .set("border", "2px dashed var(--lumo-contrast-30pct)") + .set("border-radius", "var(--lumo-border-radius-l)") + .setWidth("600px") + .setHeight("400px"); + + add(controls, description, box); + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java new file mode 100644 index 0000000..cb1aaea --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java @@ -0,0 +1,121 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2025 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.chatassistant; + +import com.flowingcode.vaadin.addons.chatassistant.model.Message; +import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.flowingcode.vaadin.addons.demo.SourcePosition; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.dependency.CssImport; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.icon.SvgIcon; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import java.time.LocalDateTime; + +@DemoSource(sourcePosition = SourcePosition.PRIMARY) +@PageTitle("FAB Configuration Demo") +@Route(value = "chat-assistant/fab-config-demo", layout = ChatAssistantDemoView.class) +@CssImport("./styles/chat-assistant-styles-demo.css") +public class ChatAssistantFabConfigDemo extends VerticalLayout { + + public ChatAssistantFabConfigDemo() { + SvgIcon icon = new SvgIcon("chatbot.svg"); + + // Build the assistant with a custom FAB icon via the builder. + ChatAssistant chatAssistant = ChatAssistant.builder().fabIcon(icon).build(); + chatAssistant.setWindowWidth("400px"); + chatAssistant.setWindowHeight("400px"); + + // Size variants resize the FAB (and its icon): SMALL (50px) and LARGE (72px); removing the active + // one restores the default (60px). Only one size is active at a time. + Button small = new Button("Small", + ev -> chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_SMALL) + ); + Button large = new Button("Large", + ev -> chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_LARGE) + ); + Button defaultSize = new Button("Default size", ev -> chatAssistant.removeFabThemeVariants(ButtonVariant.LUMO_SMALL, + ButtonVariant.LUMO_LARGE) + ); + + // Color variants are applied to the underlying button; the icon follows the button color. + Button success = new Button("Success", + ev -> chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_SUCCESS) + ); + Button error = new Button("Error", + ev -> chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_ERROR) + ); + Button contrast = new Button("Contrast", + ev -> chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_CONTRAST) + ); + Button clearColors = new Button("Clear colors", ev -> chatAssistant.removeFabThemeVariants( + ButtonVariant.LUMO_SUCCESS, ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_CONTRAST) + ); + + // Lock down or free up the FAB and the window, and toggle the resize direction hints. Each toggle + // shows a notification reporting the resulting state. + Button movable = new Button("Toggle movable", ev -> { + chatAssistant.setFabMovable(!chatAssistant.isFabMovable()); + Notification.show("FAB movable: " + chatAssistant.isFabMovable()); + }); + Button resizable = new Button("Toggle resizable", ev -> { + chatAssistant.setWindowResizable(!chatAssistant.isWindowResizable()); + Notification.show("Window resizable: " + chatAssistant.isWindowResizable()); + }); + Button indicators = new Button("Toggle resize hints", ev -> { + chatAssistant.setResizeIndicatorsVisible(!chatAssistant.isResizeIndicatorsVisible()); + Notification.show("Resize hints visible: " + chatAssistant.isResizeIndicatorsVisible()); + }); + + // Seed the conversation and open the window so the styling and resize hints are visible right away. + chatAssistant.sendMessage( + Message.builder() + .content("Use the buttons to restyle the FAB.") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build() + ); + chatAssistant.setOpened(true); + + add(section("Size variants", small, large, defaultSize), + section("Color variants", success, error, contrast, clearColors), + section("Behavior", movable, resizable, indicators), + chatAssistant); + } + + /** A titled group of buttons whose row wraps on narrow screens. */ + private static VerticalLayout section(String title, Button... buttons) { + Span heading = new Span(title); + + HorizontalLayout row = new HorizontalLayout(buttons); + row.getStyle().set("flex-wrap", "wrap"); + + VerticalLayout group = new VerticalLayout(heading, row); + group.setPadding(false); + group.setSpacing(false); + return group; + } +} From 11c39b285cb06c6cf3c50559134514a58f629f65 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:03 -0300 Subject: [PATCH 11/25] feat(demo): add modes and responsive demo Desktop/mobile modes with breakpoint auto-switching, manual setMode, a mode-changed listener with notification, FAB position reset, and a chat window screen-size threshold listener. --- .../chatassistant/ChatAssistantModeDemo.java | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java new file mode 100644 index 0000000..da79bca --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java @@ -0,0 +1,95 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2025 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.chatassistant; + +import com.flowingcode.vaadin.addons.chatassistant.model.ChatAssistantMode; +import com.flowingcode.vaadin.addons.chatassistant.model.Message; +import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.flowingcode.vaadin.addons.demo.SourcePosition; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.dependency.CssImport; +import com.vaadin.flow.component.icon.SvgIcon; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import java.time.LocalDateTime; + +@DemoSource(sourcePosition = SourcePosition.PRIMARY) +@PageTitle("Modes & Responsive Demo") +@Route(value = "chat-assistant/mode-demo", layout = ChatAssistantDemoView.class) +@CssImport("./styles/chat-assistant-styles-demo.css") +public class ChatAssistantModeDemo extends VerticalLayout { + + public ChatAssistantModeDemo() { + SvgIcon icon = new SvgIcon("chatbot.svg"); + icon.setColor("var(--lumo-primary-contrast-color)"); + + // Build the assistant with auto-switching enabled: setting a breakpoint makes it switch to + // mobile (full-screen dialog) below 768px and back to desktop (anchored popover) above it. + ChatAssistant chatAssistant = ChatAssistant.builder() + .fabIcon(icon) + .mobileBreakpoint(768) + .build(); + chatAssistant.setWindowWidth("400px"); + chatAssistant.setWindowHeight("400px"); + + // React to every mode change, whether automatic or manual. + chatAssistant.addModeChangedListener( + ev -> Notification.show("Switched to " + ev.getMode() + " mode") + ); + + // React to the chat window's own size crossing a 500px width threshold (independent of the + // viewport breakpoint above). Fires once on registration and then on every crossing. + chatAssistant.addScreenSizeListener(500, null, + ev -> Notification.show("Chat window is now " + ev.getDirection() + " 500px wide") + ); + + // Switch the mode manually (only sticks while auto-switching is disabled). + Button mobile = new Button("Set mobile", ev -> chatAssistant.setMode(ChatAssistantMode.MOBILE)); + Button desktop = new Button("Set desktop", ev -> chatAssistant.setMode(ChatAssistantMode.DESKTOP)); + + // Freeze/resume automatic switching on the configured breakpoint. + Button toggleSwitching = new Button("Toggle auto switching", ev -> { + chatAssistant.setMobileModeSwitchingEnabled(!chatAssistant.isMobileModeSwitchingEnabled()); + Notification.show("Auto switching: " + chatAssistant.isMobileModeSwitchingEnabled()); + }); + + // Move the FAB back to its configured corner after it has been dragged. + Button reset = new Button("Reset FAB position", ev -> chatAssistant.resetFabPosition()); + + HorizontalLayout controls = new HorizontalLayout(mobile, desktop, toggleSwitching, reset); + controls.getStyle().set("flex-wrap", "wrap"); + + // Seed the conversation with a greeting and open the window. + chatAssistant.sendMessage( + Message.builder() + .content("Resize the window to switch modes.") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build() + ); + chatAssistant.setOpened(true); + + add(controls, chatAssistant); + } +} From 2889c73ecf0d331ef6fb8341a9ab4597d133908a Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:03 -0300 Subject: [PATCH 12/25] feat(demo): register the demo views in the tabbed view --- .../vaadin/addons/chatassistant/ChatAssistantDemoView.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemoView.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemoView.java index c4d151b..9e5dd8d 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemoView.java @@ -25,14 +25,19 @@ import com.vaadin.flow.router.ParentLayout; import com.vaadin.flow.router.Route; -@SuppressWarnings("serial") @ParentLayout(DemoLayout.class) @Route("chat-assistant") @GithubLink("https://github.com/FlowingCode/ChatAssistant") public class ChatAssistantDemoView extends TabbedDemo { public ChatAssistantDemoView() { + // Core usage and FAB/window configuration. addDemo(ChatAssistantDemo.class); + addDemo(ChatAssistantFabConfigDemo.class); + addDemo(ChatAssistantBoxDemo.class); + addDemo(ChatAssistantModeDemo.class); + + // Content and data-handling features. addDemo(ChatAssistantLazyLoadingDemo.class); addDemo(ChatAssistantMarkdownDemo.class); addDemo(ChatAssistantGenerativeDemo.class); From 8d72c8839d0103e6a5be3c5da4bd93a2691abc1e Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:22 -0300 Subject: [PATCH 13/25] test: parameterize ChatAssistant type in serialization test --- .../vaadin/addons/chatassistant/test/SerializationTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/test/SerializationTest.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/test/SerializationTest.java index 79d2489..a97d46f 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/test/SerializationTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/test/SerializationTest.java @@ -45,7 +45,7 @@ private void testSerializationOf(Object obj) throws IOException, ClassNotFoundEx @Test public void testSerialization() throws ClassNotFoundException, IOException { try { - ChatAssistant chatAssistant = new ChatAssistant(); + ChatAssistant chatAssistant = new ChatAssistant(); chatAssistant.sendMessage(Message.builder().build()); testSerializationOf(chatAssistant); } catch (Exception e) { From 23a895cc374ff1d533e3587b1ee5388fd0bc60ab Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:22 -0300 Subject: [PATCH 14/25] refactor: drop redundant serial annotations --- .../flowingcode/vaadin/addons/chatassistant/model/Message.java | 1 - .../com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.java | 1 - src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java | 1 - .../vaadin/addons/chatassistant/CustomChatMessage.java | 1 - .../flowingcode/vaadin/addons/chatassistant/CustomMessage.java | 1 - .../com/flowingcode/vaadin/addons/chatassistant/DemoView.java | 1 - 6 files changed, 6 deletions(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/Message.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/Message.java index 5ffdafc..67e6408 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/Message.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/Message.java @@ -33,7 +33,6 @@ * * @author mmlopez */ -@SuppressWarnings("serial") @Getter @Setter @SuperBuilder diff --git a/src/test/java/com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.java b/src/test/java/com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.java index 4860552..8239715 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.java +++ b/src/test/java/com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.java @@ -24,7 +24,6 @@ import com.vaadin.flow.component.page.Push; import com.vaadin.flow.server.AppShellSettings; -@SuppressWarnings("serial") @Push public class AppShellConfiguratorImpl implements AppShellConfigurator { diff --git a/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java b/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java index e633078..4fdfb59 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java +++ b/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java @@ -22,7 +22,6 @@ import com.vaadin.flow.component.html.Div; import com.vaadin.flow.router.RouterLayout; -@SuppressWarnings("serial") public class DemoLayout extends Div implements RouterLayout { public DemoLayout() { diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomChatMessage.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomChatMessage.java index 47e2627..93a8220 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomChatMessage.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomChatMessage.java @@ -2,7 +2,6 @@ import com.vaadin.flow.component.html.Span; -@SuppressWarnings("serial") public class CustomChatMessage extends ChatMessage { private Span tagline = new Span(); diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomMessage.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomMessage.java index d121cce..f052dc6 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomMessage.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomMessage.java @@ -6,7 +6,6 @@ import lombok.Setter; import lombok.experimental.SuperBuilder; -@SuppressWarnings("serial") @EqualsAndHashCode(callSuper = true) @Getter @Setter diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/DemoView.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/DemoView.java index 622b93d..4dac2b2 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/DemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/DemoView.java @@ -25,7 +25,6 @@ import com.vaadin.flow.router.BeforeEnterObserver; import com.vaadin.flow.router.Route; -@SuppressWarnings("serial") @Route("") public class DemoView extends VerticalLayout implements BeforeEnterObserver { From 51722aafb893427b9ab561ee98599585c0bd2ddf Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:22 -0300 Subject: [PATCH 15/25] test: tidy integration test imports and assertions --- .../flowingcode/vaadin/addons/chatassistant/it/BasicIT.java | 1 - .../flowingcode/vaadin/addons/chatassistant/it/ViewIT.java | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/BasicIT.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/BasicIT.java index 11f6202..9d118de 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/BasicIT.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/BasicIT.java @@ -23,7 +23,6 @@ import com.flowingcode.vaadin.addons.chatassistant.it.po.ChatBubbleElement; import com.vaadin.flow.component.button.testbench.ButtonElement; import com.vaadin.flow.component.html.testbench.NativeButtonElement; -import com.vaadin.flow.component.html.testbench.ParagraphElement; import com.vaadin.flow.component.notification.testbench.NotificationElement; import com.vaadin.flow.component.textfield.testbench.TextAreaElement; import com.vaadin.testbench.TestBenchElement; diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/ViewIT.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/ViewIT.java index 836271c..7f6787b 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/ViewIT.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/ViewIT.java @@ -22,11 +22,11 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; -import static org.junit.Assert.assertThat; import com.vaadin.testbench.TestBenchElement; import org.hamcrest.Description; import org.hamcrest.Matcher; +import org.hamcrest.MatcherAssert; import org.hamcrest.TypeSafeDiagnosingMatcher; import org.junit.Test; @@ -59,6 +59,6 @@ protected boolean matchesSafely(TestBenchElement item, Description mismatchDescr @Test public void componentWorks() { TestBenchElement element = $("chat-bot").first(); - assertThat(element, hasBeenUpgradedToCustomElement); + MatcherAssert.assertThat(element, hasBeenUpgradedToCustomElement); } } From 24ad44b9793a4ea9912b0c859e7dce89e5ad08ee Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:22 -0300 Subject: [PATCH 16/25] chore: ignore generated web-component.html --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index de849b2..96e6271 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ drivers /types.d.ts* /frontend/generated /frontend/index.html +/frontend/web-component.html +/src/main/frontend/web-component.html /src/main/frontend/generated /src/main/frontend/index.html vite.generated.ts From b9bf47b08d039d813f5c46bc5ae537087a2bdf7b Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:22 -0300 Subject: [PATCH 17/25] docs(readme): document FAB, window, and mode features Expand the feature list and replace the outdated getting-started example with current builder/setter-based tutorials covering messaging, FAB styling, window sizing, and responsive mobile mode. --- README.md | 104 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 83 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 9b0cdc9..a99ea5f 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,16 @@ Vaadin Add-on that displays a chat assistant floating window using [Material UI' ## Features -* Messages can be sent by the user or programmatically. -* Listen for new messages written by the user. -* Toggle the chat window on/off. +* Send messages from the user or programmatically, and listen for messages written by the user. +* Toggle the chat window open/closed, or open it as a full-screen dialog on mobile. +* Markdown rendering, lazy loading via a `DataProvider`, and streaming ("generative") answers. +* Customizable floating action button (FAB): icon, size, color (`ButtonVariant` theme variants), + corner position and margin, draggable or fixed, and viewport- or container-anchored placement. +* Resizable chat window with eight drag handles, optional resize direction indicators, and + configurable initial size with min/max bounds. +* Responsive desktop/mobile modes with optional breakpoint-based auto-switching, plus listeners for + mode changes and for the chat window crossing a size threshold. +* A fluent `ChatAssistant.builder()` to configure everything declaratively. ## Supported versions @@ -96,24 +103,79 @@ Chat Assistant Add-on is written by Flowing Code S.A. ## Getting started -Simple example showing the basic options: - - ChatAssistant chatAssistant = new ChatAssistant(); - TextArea message = new TextArea(); - message.setLabel("Enter a message from the assistant"); - message.setSizeFull(); - - Button chat = new Button("Chat"); - chat.addClickListener(ev->{ - Message m = new Message(message.getValue(),false,false,0,false,new Sender("Assistant","1","https://ui-avatars.com/api/?name=Bot")); - chatAssistant.sendMessage(m); - message.clear(); - }); - chatAssistant.sendMessage(new Message("Hello, I am here to assist you",false,false,0,false,new Sender("Assistant","1","https://ui-avatars.com/api/?name=Bot"))); - chatAssistant.toggle(); - chatAssistant.addChatSentListener(ev->{ - Notification.show(ev.getMessage()); - }); +`ChatAssistant` renders a floating action button (FAB) that opens a chat window. +Add it to any layout and send messages to it; it shows a FAB in the bottom-right corner by default. + +```java +ChatAssistant chatAssistant = new ChatAssistant<>(); +add(chatAssistant); + +// Send a message programmatically (e.g. from the assistant). +chatAssistant.sendMessage(Message.builder() + .name("Assistant") + .content("Hello, I am here to assist you") + .messageTime(LocalDateTime.now()) + .build()); + +// React to messages typed by the user. +chatAssistant.setSubmitListener(ev -> + chatAssistant.sendMessage(Message.builder() + .name("User") + .content(ev.getValue()) + .messageTime(LocalDateTime.now()) + .build())); + +// Open or close the window programmatically. +chatAssistant.setOpened(true); +``` + +### Configuring with the builder + +Use `ChatAssistant.builder()` to configure the FAB and window declaratively: + +```java +ChatAssistant chatAssistant = ChatAssistant.builder() + .fabIcon(new SvgIcon("icons/my-icon.svg")) // custom FAB icon (defaults to a chatbot icon) + .defaultFabPosition(FabPosition.BOTTOM_LEFT) + .fabMovable(true) // allow dragging the FAB + .resizable(true) // allow resizing the window + .markdownEnabled(true) // render message content as Markdown + .build(); +``` + +Everything in the builder also has a setter, so the FAB and window can be reconfigured at runtime. + +### Styling the FAB + +```java +chatAssistant.setFabPosition(FabPosition.TOP_RIGHT); // move it (and set the reset corner) +chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_LARGE); // grow the FAB (LUMO_SMALL/LUMO_LARGE) +chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_CONTRAST); // recolor it (color variants) +chatAssistant.setResizeIndicatorsVisible(true); // show resize-direction hints +``` + +### Sizing the window + +```java +chatAssistant.setWindowWidth("400px"); +chatAssistant.setWindowHeight("500px"); +chatAssistant.setWindowMinWidth(300); // bounds honored on open and while resizing +chatAssistant.setWindowMaxWidth(700); +``` + +### Responsive / mobile mode + +In `MOBILE` mode the chat opens as a full-screen dialog. Set a breakpoint to switch automatically +between desktop (anchored popover) and mobile as the viewport crosses it: + +```java +ChatAssistant chatAssistant = ChatAssistant.builder() + .mobileBreakpoint(768) // auto-switch to mobile below 768px (auto-switching is opt-in) + .build(); + +chatAssistant.addModeChangedListener(ev -> Notification.show("Now in " + ev.getMode() + " mode")); +chatAssistant.setMode(ChatAssistantMode.MOBILE); // or switch manually +``` ## Special configuration when using Spring From d98608eede1d3862299b1e33f10c142fa55570f6 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Wed, 1 Jul 2026 10:12:16 -0300 Subject: [PATCH 18/25] feat: add FAB variants and harden the 5.1.0 API Follow-up work on the 5.1.0 feature set, collected into one commit. Public API: - Add a FabVariant enum for FAB sizing and color that works under both Lumo and Aura; the color variants also carry an Aura accent class, because Aura styles accent colors by class rather than by theme attribute. - Expose width, height, maxWidth and maxHeight on the builder, make its all-args constructor private and switch its flags to primitives. - Make isFabMovable() report the effective state (movable and anchored). - Accept any CSS length in the window-size setters. - Reduce the protected surface: the internal FAB, resizer and icon helpers are now private, and the @since tags are corrected against 5.0.0. Robustness: - Register a minimal animated-fab custom element so disconnectedCallback really runs: the window listeners, style observer, overlay-lookup timeout and screen-size observers no longer leak on detach, and the movement and resize init guards are cleared so a reattach re-initializes. - Keep a duplicate ChatAssistant inert with a warning instead of throwing from onAttach, where it could break route navigation. - Load the default chatbot icon lazily and degrade to no icon with a warning instead of failing at class-load time. - Observe the container surface in addScreenSizeListener so it also fires in mobile mode, and re-deliver the size when the mobile dialog opens. - Blank a recycled VirtualList row's content while it shows the loader. - Ignore sub-threshold pointer movement so a click no longer nudges the FAB, and reject a negative FAB margin. - Defer the window size and constraint push until the popover is open, dropping six pointless polling loops at construction time. - Reset the unread badge text color on whitespace-only input. Internals: - Hold the eight resize handles in a direction-keyed map iterated at every site, and share the overlay resolution between the resize.js helpers. - Drop the write-only minWidth/minHeight fields; the overlay's --fc-min-* custom properties are the state. - Merge the duplicated popover and dialog ::part(content) rules and make the stylesheets valid under lightningcss (Vaadin 25.2), and replace the hardcoded Lumo tokens with cross-theme fallback chains. - Apply Google Java Style and normalize the copyright year range. Tests and demos: - Add ChatAssistantLogicTest covering the pure-Java logic, and exercise the screen-size listener state in SerializationTest. - Target the animated-fab tag in the integration tests and page objects. - Replace the leaked Timer and the common-pool usage in the demos with daemon executors that are shut down on detach, and abort generative streaming on interrupt. Close #69 Close #67 --- README.md | 6 +- .../addons/chatassistant/ChatAssistant.java | 1248 ++++++++++------- .../addons/chatassistant/ChatMessage.java | 66 +- .../model/ChatAssistantMode.java | 8 +- .../chatassistant/model/FabPosition.java | 12 +- .../chatassistant/model/FabVariant.java | 86 ++ .../addons/chatassistant/model/Message.java | 9 +- .../META-INF/VAADIN/package.properties | 19 + .../frontend/fc-chat-assistant-movement.js | 53 +- .../frontend/fc-chat-assistant-resize.js | 78 +- .../styles/fc-chat-assistant-style.css | 58 +- .../styles/fc-chat-message-styles.css | 10 +- .../META-INF/resources/icons/chatbot.svg | 20 + .../addons/AppShellConfiguratorImpl.java | 1 - .../flowingcode/vaadin/addons/DemoLayout.java | 2 +- .../chatassistant/ChatAssistantBoxDemo.java | 32 +- .../chatassistant/ChatAssistantDemo.java | 159 ++- .../chatassistant/ChatAssistantDemoView.java | 2 +- .../ChatAssistantFabConfigDemo.java | 136 +- .../ChatAssistantGenerativeDemo.java | 192 +-- .../ChatAssistantLazyLoadingDemo.java | 279 ++-- .../chatassistant/ChatAssistantLogicTest.java | 177 +++ .../ChatAssistantMarkdownDemo.java | 61 +- .../chatassistant/ChatAssistantModeDemo.java | 50 +- .../chatassistant/CustomChatMessage.java | 24 +- .../addons/chatassistant/CustomMessage.java | 22 +- .../vaadin/addons/chatassistant/DemoView.java | 2 +- .../chatassistant/it/AbstractViewTest.java | 2 +- .../addons/chatassistant/it/BasicIT.java | 4 +- .../addons/chatassistant/it/ViewIT.java | 7 +- .../it/po/ChatAssistantElement.java | 4 +- .../it/po/ChatBubbleElement.java | 2 +- .../chatassistant/test/SerializationTest.java | 6 +- .../styles/chat-assistant-styles-demo.css | 8 +- .../resources/META-INF/resources/chatbot.svg | 20 + 35 files changed, 1794 insertions(+), 1071 deletions(-) create mode 100644 src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabVariant.java create mode 100644 src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java diff --git a/README.md b/README.md index a99ea5f..a48df43 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Vaadin Add-on that displays a chat assistant floating window using [Material UI' * Send messages from the user or programmatically, and listen for messages written by the user. * Toggle the chat window open/closed, or open it as a full-screen dialog on mobile. * Markdown rendering, lazy loading via a `DataProvider`, and streaming ("generative") answers. -* Customizable floating action button (FAB): icon, size, color (`ButtonVariant` theme variants), +* Customizable floating action button (FAB): icon, size, color (`FabVariant` theme variants), corner position and margin, draggable or fixed, and viewport- or container-anchored placement. * Resizable chat window with eight drag handles, optional resize direction indicators, and configurable initial size with min/max bounds. @@ -149,8 +149,8 @@ Everything in the builder also has a setter, so the FAB and window can be reconf ```java chatAssistant.setFabPosition(FabPosition.TOP_RIGHT); // move it (and set the reset corner) -chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_LARGE); // grow the FAB (LUMO_SMALL/LUMO_LARGE) -chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_CONTRAST); // recolor it (color variants) +chatAssistant.addFabThemeVariants(FabVariant.LARGE); // grow the FAB (FabVariant.SMALL/LARGE) +chatAssistant.addFabThemeVariants(FabVariant.LUMO_CONTRAST); // recolor it (color variants) chatAssistant.setResizeIndicatorsVisible(true); // show resize-direction hints ``` diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java index 0cfd635..87ad349 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java @@ -2,14 +2,14 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -21,13 +21,17 @@ import com.flowingcode.vaadin.addons.chatassistant.model.ChatAssistantMode; import com.flowingcode.vaadin.addons.chatassistant.model.FabPosition; +import com.flowingcode.vaadin.addons.chatassistant.model.FabVariant; import com.flowingcode.vaadin.addons.chatassistant.model.Message; import com.vaadin.flow.component.AttachEvent; import com.vaadin.flow.component.ClientCallable; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.ComponentEvent; import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.ComponentUtil; +import com.vaadin.flow.component.DetachEvent; import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.UI; import com.vaadin.flow.component.avatar.Avatar; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.ButtonVariant; @@ -55,9 +59,6 @@ import com.vaadin.flow.dom.Style.Position; import com.vaadin.flow.function.SerializableSupplier; import com.vaadin.flow.shared.Registration; - -import lombok.Builder; - import java.io.IOException; import java.io.InputStream; import java.io.Serializable; @@ -65,14 +66,22 @@ import java.util.ArrayList; import java.util.Base64; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import lombok.Builder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Component that allows to create a floating chat button that will open a chat window that can be * used to provide a chat assistant feature. * + *

Only one {@code ChatAssistant} is supported per {@link UI}: the FAB and chat window are + * UI-level surfaces. If a second instance is attached to the same UI it stays inactive and hidden + * (a warning is logged) instead of competing with the already-attached instance. + * * @author mmlopez */ @JsModule("./fc-chat-assistant-movement.js") @@ -81,113 +90,129 @@ @Tag("animated-fab") public class ChatAssistant extends Div { - protected SvgIcon fabIcon = createDefaultFabIcon(); - // The icon component currently shown in the FAB; the single source of truth for icon sizing (fabIcon is - // SvgIcon-typed and becomes null for non-SvgIcon icons, so it can't be used for that). - protected Component fabIconComponent = fabIcon; - protected boolean resizable = DEFAULT_WINDOW_RESIZABLE; - protected boolean fabMovable = DEFAULT_FAB_MOVABLE; - protected ChatAssistantMode mode = DEFAULT_MODE; - protected int mobileBreakpoint = DEFAULT_MOBILE_BREAKPOINT; - protected boolean mobileModeSwitchingEnabled = false; - protected boolean fabAnchoredToViewport = DEFAULT_FAB_ANCHORED_TO_VIEWPORT; - protected boolean resizeIndicatorsVisible = DEFAULT_RESIZE_INDICATORS_VISIBLE; - protected FabPosition fabPosition = DEFAULT_POSITION; - protected int fabMargin = DEFAULT_FAB_MARGIN; - protected int fabSize = DEFAULT_FAB_SIZE; - protected ButtonVariant activeSizeVariant = null; - protected String minWidth = DEFAULT_CONTENT_MIN_WIDTH + "px"; - protected String minHeight = DEFAULT_CONTENT_MIN_HEIGHT + "px"; + protected Component fabIcon = createDefaultFabIcon(); protected final Button fab = new Button(); protected final Div unreadBadge = new Div(); protected final Div fabWrapper = new Div(fab, unreadBadge); protected final Popover chatWindow = new Popover(); - protected final Dialog mobileChatWindow = new Dialog(); protected final Div overlay = new Div(); protected final VerticalLayout container = new VerticalLayout(); - protected final Div resizerTop = new Div(); - protected final Div resizerBottom = new Div(); - protected final Div resizerTopRight = new Div(); - protected final Div resizerBottomRight = new Div(); - protected final Div resizerRight = new Div(); - protected final Div resizerLeft = new Div(); - protected final Div resizerBottomLeft = new Div(); - protected final Div resizerTopLeft = new Div(); + /** The resize handle directions, in creation order. */ + private static final List RESIZER_DIRECTIONS = + List.of( + "top", "bottom", "right", "top-right", "bottom-right", "left", "top-left", "bottom-left"); + + /** The eight resize handles keyed by direction (see {@link #RESIZER_DIRECTIONS}). */ + private final Map resizers = new LinkedHashMap<>(); protected static final int DEFAULT_FAB_SIZE = 60; - protected static final int DEFAULT_FAB_SMALL_SIZE = 50; - protected static final int DEFAULT_FAB_LARGE_SIZE = 72; - protected static final boolean DEFAULT_FAB_ANCHORED_TO_VIEWPORT = true; - protected static final boolean DEFAULT_RESIZE_INDICATORS_VISIBLE = false; - protected static final boolean DEFAULT_WINDOW_RESIZABLE = true; - protected static final boolean DEFAULT_FAB_MOVABLE = true; - protected static final ChatAssistantMode DEFAULT_MODE = ChatAssistantMode.DESKTOP; - protected static final int DEFAULT_MOBILE_BREAKPOINT = 768; protected static final int DEFAULT_FAB_ICON_SIZE = 40; protected static final int DEFAULT_FAB_MARGIN = 25; protected static final int DEFAULT_RESIZER_SIZE = 25; protected static final int DEFAULT_MAX_RESIZER_SIZE = 200; protected static final int DEFAULT_DRAG_SENSITIVITY = 25; - protected static final FabPosition DEFAULT_POSITION = FabPosition.BOTTOM_RIGHT; - protected static final String DEFAULT_UNREAD_BADGE_BACKGROUND = "var(--lumo-warning-color)"; - protected static final String DEFAULT_UNREAD_BADGE_COLOR = "var(--lumo-warning-text-color)"; - - protected static final int DEFAULT_CONTENT_MIN_WIDTH = 150; - protected static final int DEFAULT_CONTENT_MIN_HEIGHT = 150; - protected static final String DEFAULT_POPOVER_TAG = "fc-chat-assistant-popover"; - protected static final String DEFAULT_DIALOG_TAG = "fc-chat-assistant-dialog"; - protected static final String DEFAULT_FAB_CLASS = "fc-chat-assistant-fab"; - protected static final String DEFAULT_RESIZE_CLASS = "fc-chat-assistant-resize"; - protected static final String RESIZE_INDICATOR_VISIBLE_CLASS = - "fc-chat-assistant-resize-indicator-visible"; - protected static final String DEFAULT_UNREAD_BADGE_CLASS = "fc-chat-assistant-unread-badge"; - protected static final String DEFAULT_FAB_ICON_SRC = loadDefaultFabIconSrc(); - - protected final VirtualList content = new VirtualList<>(); - protected final List messages = new ArrayList<>(); - - protected Component headerComponent; - protected Component footerContainer; - protected MessageInput messageInput; - protected Span whoIsTyping; - protected Registration defaultSubmitListenerRegistration; - protected int unreadMessages = 0; - private int screenSizeKeySeq = 0; + private static final ChatAssistantMode DEFAULT_MODE = ChatAssistantMode.DESKTOP; + private static final FabPosition DEFAULT_POSITION = FabPosition.BOTTOM_RIGHT; + private static final boolean DEFAULT_FAB_ANCHORED_TO_VIEWPORT = true; + private static final boolean DEFAULT_RESIZE_INDICATORS_VISIBLE = false; + private static final boolean DEFAULT_WINDOW_RESIZABLE = true; + private static final boolean DEFAULT_FAB_MOVABLE = true; + private static final int DEFAULT_CONTENT_MIN_WIDTH = 150; + private static final int DEFAULT_CONTENT_MIN_HEIGHT = 150; + private static final int DEFAULT_FAB_SMALL_SIZE = 50; + private static final int DEFAULT_FAB_LARGE_SIZE = 72; + private static final int DEFAULT_MOBILE_BREAKPOINT = 768; + private static final String DEFAULT_POPOVER_TAG = "fc-chat-assistant-popover"; + private static final String DEFAULT_DIALOG_TAG = "fc-chat-assistant-dialog"; + private static final String DEFAULT_FAB_CLASS = "fc-chat-assistant-fab"; + private static final String DEFAULT_RESIZE_CLASS = "fc-chat-assistant-resize"; + private static final String DEFAULT_UNREAD_BADGE_CLASS = "fc-chat-assistant-unread-badge"; + private static final String RESIZE_INDICATOR_VISIBLE_CLASS = "fc-chat-assistant-resize-indicator-visible"; + // Theme-agnostic token fallback chains (Lumo token, then Aura token, then a literal) so the badge + // renders correctly under both the Lumo and Aura themes of Vaadin 25. + private static final String DEFAULT_UNREAD_BADGE_BACKGROUND ="var(--lumo-warning-color, var(--aura-accent-yellow, #e07a00))"; + private static final String DEFAULT_UNREAD_BADGE_COLOR = "var(--lumo-warning-contrast-color, var(--aura-accent-contrast-color, #ffffff))"; + // Shared spacing/typography tokens used for the badge, with the same cross-theme fallback pattern. + private static final String BADGE_FONT_SIZE = "var(--lumo-font-size-xs, var(--aura-font-size-s, 0.75rem))"; + private static final String BADGE_PADDING = "var(--lumo-space-xs, var(--vaadin-padding-xs, 0.25rem))"; + // Key under which the active ChatAssistant is tracked on its UI to enforce a single instance per UI. + private static final String UI_INSTANCE_KEY = "fc-chat-assistant-ui-instance"; + // Custom CSS properties that carry the chat window size constraints to the resize frontend. + private static final String CSS_WIDTH = "--fc-width"; + private static final String CSS_HEIGHT = "--fc-height"; + private static final String CSS_MIN_WIDTH = "--fc-min-width"; + private static final String CSS_MIN_HEIGHT = "--fc-min-height"; + private static final String CSS_MAX_WIDTH = "--fc-max-width"; + private static final String CSS_MAX_HEIGHT = "--fc-max-height"; + private static final Logger LOGGER = LoggerFactory.getLogger(ChatAssistant.class); + + /** Lazily loaded and cached data URI of the bundled chatbot SVG (see {@link #getDefaultFabIconSrc()}). */ + private static volatile String defaultFabIconSrc; + private final Map screenSizeListeners = new HashMap<>(); + private final Dialog mobileChatWindow = new Dialog(); + + private final VirtualList content = new VirtualList<>(); + private final List messages = new ArrayList<>(); + + private MessageInput messageInput; + private ChatAssistantMode mode = DEFAULT_MODE; + private Component headerComponent; + private Component footerContainer; + private FabPosition fabPosition = DEFAULT_POSITION; + private FabVariant activeSizeVariant = null; + private Span whoIsTyping; + private Registration defaultSubmitListenerRegistration; + private boolean resizable = DEFAULT_WINDOW_RESIZABLE; + private boolean fabMovable = DEFAULT_FAB_MOVABLE; + private boolean desktopFabMovablePreference = DEFAULT_FAB_MOVABLE; + private boolean mobileModeSwitchingEnabled = false; + private boolean fabAnchoredToViewport = DEFAULT_FAB_ANCHORED_TO_VIEWPORT; + private boolean resizeIndicatorsVisible = DEFAULT_RESIZE_INDICATORS_VISIBLE; + private int mobileBreakpoint = DEFAULT_MOBILE_BREAKPOINT; + private int fabMargin = DEFAULT_FAB_MARGIN; + private int fabSize = DEFAULT_FAB_SIZE; + private int screenSizeKeySeq = 0; + private int unreadMessages = 0; /** * Creates a ChatAssistant with the given initial messages, using the defaults for every other - * setting. The messages are copied; later changes to the supplied list are not reflected. - *

- * To configure multiple aspects at construction time, prefer {@code ChatAssistant.builder()}. + * setting. The messages are copied defensively: later changes to the supplied list are not + * reflected, and the component's own updates do not leak back into it. Mutate the conversation + * through {@link #sendMessage(Message)} rather than the supplied list. + * + *

To configure multiple aspects at construction time, prefer {@code ChatAssistant.builder()}. * * @param messages the initial messages * @param markdownEnabled flag to enable or disable markdown support */ public ChatAssistant(List messages, boolean markdownEnabled) { this( - null, - null, - null, - null, - null, - null, - markdownEnabled, - messages, - null, - null, - null, - null - ); + null, + DEFAULT_WINDOW_RESIZABLE, + DEFAULT_FAB_MOVABLE, + null, + DEFAULT_FAB_ANCHORED_TO_VIEWPORT, + DEFAULT_RESIZE_INDICATORS_VISIBLE, + markdownEnabled, + messages, + null, + null, + null, + null, + null, + null, + null, + null); } /** * Creates a ChatAssistant with no messages, using the defaults for every setting. - *

- * To configure multiple aspects at construction time, prefer {@code ChatAssistant.builder()}. + * + *

To configure multiple aspects at construction time, prefer {@code ChatAssistant.builder()}. */ public ChatAssistant() { this(new ArrayList<>(), false); @@ -195,8 +220,8 @@ public ChatAssistant() { /** * Creates a ChatAssistant with no messages, using the defaults for every other setting. - *

- * To configure multiple aspects at construction time, prefer {@code ChatAssistant.builder()}. + * + *

To configure multiple aspects at construction time, prefer {@code ChatAssistant.builder()}. * * @param markdownEnabled flag to enable or disable markdown support */ @@ -209,106 +234,144 @@ public ChatAssistant(boolean markdownEnabled) { * invalid it falls back to its corresponding default. * * @param fabIcon the FAB icon ({@code null} keeps the default chatbot icon) - * @param resizable whether the chat window is resizable (default {@value #DEFAULT_WINDOW_RESIZABLE}) + * @param resizable whether the chat window is resizable (default {@value + * #DEFAULT_WINDOW_RESIZABLE}) * @param fabMovable whether the FAB can be dragged (default {@value #DEFAULT_FAB_MOVABLE}) * @param mobileBreakpoint the maximum screen width in pixels below which mobile mode is activated * automatically. Providing any non-null value enables automatic switching (disabled by * default); {@code null} leaves it disabled, and {@code 0} enables switching but keeps the * component in desktop mode at any width - * @param fabAnchoredToViewport whether the FAB is anchored to the viewport ({@code true}, the default) - * or positioned within its container ({@code false}) (default {@value #DEFAULT_FAB_ANCHORED_TO_VIEWPORT}) + * @param fabAnchoredToViewport whether the FAB is anchored to the viewport ({@code true}, the + * default) or positioned within its container ({@code false}) (default {@value + * #DEFAULT_FAB_ANCHORED_TO_VIEWPORT}) * @param resizeIndicatorsVisible whether the resize handles show a direction arrowhead (default * {@value #DEFAULT_RESIZE_INDICATORS_VISIBLE}) * @param markdownEnabled whether markdown is enabled in messages * @param messages the initial messages ({@code null} starts empty) * @param defaultFabPosition the FAB's initial corner (default {@link FabPosition#BOTTOM_RIGHT}) - * @param defaultFabMargin the FAB's margin to the viewport edges in pixels (default {@value #DEFAULT_FAB_MARGIN}) + * @param defaultFabMargin the FAB's margin to the viewport edges in pixels (default {@value + * #DEFAULT_FAB_MARGIN}) * @param minWidth the chat window minimum width ({@code null} keeps the default) * @param minHeight the chat window minimum height ({@code null} keeps the default) + * @param width the chat window's initial width as an absolute CSS length ({@code null} keeps the + * default) + * @param height the chat window's initial height as an absolute CSS length ({@code null} keeps + * the default) + * @param maxWidth the chat window maximum width ({@code null} keeps the default) + * @param maxHeight the chat window maximum height ({@code null} keeps the default) */ @Builder - public ChatAssistant( - SvgIcon fabIcon, - Boolean resizable, - Boolean fabMovable, - Integer mobileBreakpoint, - Boolean fabAnchoredToViewport, - Boolean resizeIndicatorsVisible, - Boolean markdownEnabled, - List messages, - FabPosition defaultFabPosition, - String defaultFabMargin, - String minWidth, - String minHeight - ) { + private ChatAssistant( + SvgIcon fabIcon, + boolean resizable, + boolean fabMovable, + Integer mobileBreakpoint, + boolean fabAnchoredToViewport, + boolean resizeIndicatorsVisible, + boolean markdownEnabled, + List messages, + FabPosition defaultFabPosition, + String defaultFabMargin, + String minWidth, + String minHeight, + String width, + String height, + String maxWidth, + String maxHeight) { if (messages != null) { this.messages.addAll(messages); } this.setUI( - fabIcon, - resizable, - fabMovable, - mobileBreakpoint, - fabAnchoredToViewport, - resizeIndicatorsVisible, - defaultFabPosition, - defaultFabMargin, - minWidth, - minHeight - ); + fabIcon, + resizable, + fabMovable, + mobileBreakpoint, + fabAnchoredToViewport, + resizeIndicatorsVisible, + defaultFabPosition, + defaultFabMargin, + minWidth, + minHeight, + width, + height, + maxWidth, + maxHeight); this.initializeHeader(); this.initializeFooter(); - this.initializeContent(markdownEnabled != null && markdownEnabled); + this.initializeContent(markdownEnabled); this.initializeChatWindow(); } + /** Seeds the builder defaults for the flags whose real default is not {@code false}. */ + public static class ChatAssistantBuilder { + private boolean resizable = DEFAULT_WINDOW_RESIZABLE; + private boolean fabMovable = DEFAULT_FAB_MOVABLE; + private boolean fabAnchoredToViewport = DEFAULT_FAB_ANCHORED_TO_VIEWPORT; + } + /** * Initializes the UI applying the given configuration. Any {@code null} or invalid value falls * back to its corresponding default. */ private void setUI( SvgIcon fabIcon, - Boolean resizable, - Boolean fabMovable, + boolean resizable, + boolean fabMovable, Integer mobileBreakpoint, - Boolean fabAnchoredToViewport, - Boolean resizeIndicatorsVisible, + boolean fabAnchoredToViewport, + boolean resizeIndicatorsVisible, FabPosition fabPosition, String fabMargin, String minWidth, - String minHeight - ) { - String fontSize = "var(--lumo-font-size-xs)"; + String minHeight, + String width, + String height, + String maxWidth, + String maxHeight) { + String fontSize = BADGE_FONT_SIZE; getStyle().setZIndex(1000); // The overlay fills the popover content part (which Vaadin clamps to the viewport); resizing // writes the desired size onto the popover content, never onto this Div, so it can't overflow. // The resize bounds live in custom properties read by the resize script (setting them here has // no layout effect on the 100% Div, avoiding any overflow). - overlay.getStyle() - .setDisplay(Display.FLEX) - .setAlignItems(AlignItems.STRETCH) - .set("flex", "1") - .setMaxHeight("100%") - .setBoxSizing(Style.BoxSizing.BORDER_BOX) - .set("--fc-min-width", DEFAULT_CONTENT_MIN_WIDTH + "px") - .set("--fc-min-height", DEFAULT_CONTENT_MIN_HEIGHT + "px"); + overlay + .getStyle() + .setDisplay(Display.FLEX) + .setAlignItems(AlignItems.STRETCH) + .set("flex", "1") + .setMaxHeight("100%") + .setBoxSizing(Style.BoxSizing.BORDER_BOX) + // Allow the overlay to shrink below its content size so the message VirtualList scrolls + // internally under the popover's viewport clamp instead of forcing the window taller. + .setMinWidth("0") + .setMinHeight("0") + .set(CSS_MIN_WIDTH, DEFAULT_CONTENT_MIN_WIDTH + "px") + .set(CSS_MIN_HEIGHT, DEFAULT_CONTENT_MIN_HEIGHT + "px"); mobileChatWindow.setSizeFull(); mobileChatWindow.setModal(false); mobileChatWindow.setDraggable(false); mobileChatWindow.setResizable(false); mobileChatWindow.addClassName(DEFAULT_DIALOG_TAG); + // The dialog content is laid out on open; re-deliver the current size to each listener, matching + // the desktop popover behavior (once-per-open initial delivery). + mobileChatWindow.addOpenedChangeListener( + ev -> { + if (ev.isOpened()) { + screenSizeListeners.keySet().forEach(this::applyScreenSizeListener); + } + }); this.fabIcon = fabIcon != null ? fabIcon : createDefaultFabIcon(); - this.fabIconComponent = this.fabIcon; fab.getStyle().setBorderRadius("50%"); fab.setIcon(this.fabIcon); fab.addClassName(DEFAULT_FAB_CLASS); fab.addThemeVariants(ButtonVariant.LUMO_PRIMARY); - fabWrapper.getStyle() + fabWrapper + .getStyle() .setDisplay(Style.Display.INLINE_FLEX) .setAlignItems(Style.AlignItems.CENTER) .setJustifyContent(Style.JustifyContent.CENTER) @@ -319,17 +382,18 @@ private void setUI( unreadBadge.setText(String.valueOf(unreadMessages)); unreadBadge.addClassName(DEFAULT_UNREAD_BADGE_CLASS); - unreadBadge.getStyle() + unreadBadge + .getStyle() .setTextAlign(Style.TextAlign.CENTER) .setPosition(Style.Position.ABSOLUTE) .setJustifyContent(Style.JustifyContent.CENTER) .setAlignItems(Style.AlignItems.CENTER) .setDisplay(Style.Display.FLEX) - .setPadding("var(--lumo-space-xs)") + .setPadding(BADGE_PADDING) .setFontWeight(Style.FontWeight.BOLD) .setFontSize(fontSize) .setBorderRadius("50%") - .setBackgroundColor("var(--lumo-warning-color)") + .setBackgroundColor(DEFAULT_UNREAD_BADGE_BACKGROUND) .setScale("0") .setMinHeight(fontSize) .setMinWidth(fontSize) @@ -339,76 +403,21 @@ private void setUI( .setMaxWidth(fontSize) .setTop("0") .setRight("0") - .setColor("var(--lumo-warning-contrast-color)"); + .setColor(DEFAULT_UNREAD_BADGE_COLOR); chatWindow.add(overlay); chatWindow.setPosition(PopoverPosition.TOP); chatWindow.addClassName(DEFAULT_POPOVER_TAG); - chatWindow.setOpenOnClick(false); chatWindow.setTarget(fab); - applyGenericResizerStyle(resizerTop, "top"); - resizerTop.getStyle() - .setTop("0") - .setLeft("0") - .setHeight(DEFAULT_RESIZER_SIZE + "px") - .setWidth("100%"); - - applyGenericResizerStyle(resizerBottom, "bottom"); - resizerBottom.getStyle() - .setBottom("0") - .setLeft("0") - .setHeight(DEFAULT_RESIZER_SIZE + "px") - .setWidth("100%"); - - applyGenericResizerStyle(resizerTopRight, "top-right"); - resizerTopRight.getStyle() - .setRight("0") - .setTop("0") - .setHeight(DEFAULT_RESIZER_SIZE + "px") - .setWidth(DEFAULT_RESIZER_SIZE + "px"); - - applyGenericResizerStyle(resizerBottomRight, "bottom-right"); - resizerBottomRight.getStyle() - .setBottom("0") - .setRight("0") - .setHeight(DEFAULT_RESIZER_SIZE + "px") - .setWidth(DEFAULT_RESIZER_SIZE + "px"); - - applyGenericResizerStyle(resizerRight, "right"); - resizerRight.getStyle() - .setTop("0") - .setRight("0") - .setHeight("100%") - .setWidth(DEFAULT_RESIZER_SIZE + "px"); - - applyGenericResizerStyle(resizerLeft, "left"); - resizerLeft.getStyle() - .setTop("0") - .setLeft("0") - .setHeight("100%") - .setWidth(DEFAULT_RESIZER_SIZE + "px"); - - applyGenericResizerStyle(resizerBottomLeft, "bottom-left"); - resizerBottomLeft.getStyle() - .setBottom("0") - .setLeft("0") - .setHeight(DEFAULT_RESIZER_SIZE + "px") - .setWidth(DEFAULT_RESIZER_SIZE + "px"); - - applyGenericResizerStyle(resizerTopLeft, "top-left"); - resizerTopLeft.getStyle() - .setTop("0") - .setLeft("0") - .setHeight(DEFAULT_RESIZER_SIZE + "px") - .setWidth(DEFAULT_RESIZER_SIZE + "px"); - - overlay.add( - resizerTop, resizerBottom, - resizerRight, resizerTopRight, resizerBottomRight, - resizerLeft, resizerTopLeft, resizerBottomLeft, - container - ); + for (String direction : RESIZER_DIRECTIONS) { + Div resizer = new Div(); + applyGenericResizerStyle(resizer, direction); + applyResizerEdgeStyle(resizer, direction); + resizers.put(direction, resizer); + overlay.add(resizer); + } + overlay.add(container); this.fabPosition = fabPosition != null ? fabPosition : DEFAULT_POSITION; this.fabMargin = parseFabMargin(fabMargin); @@ -419,30 +428,45 @@ private void setUI( if (minHeight != null) { setWindowMinHeight(minHeight); } + if (maxWidth != null) { + setWindowMaxWidth(maxWidth); + } + if (maxHeight != null) { + setWindowMaxHeight(maxHeight); + } + if (width != null) { + setWindowWidth(width); + } + if (height != null) { + setWindowHeight(height); + } - // Auto-switching is opt-in: it is enabled only when the user explicitly defines a breakpoint in - // the constructor (or later via setMobileModeSwitchingEnabled). This avoids a breaking change and - // ensures mobile mode is only used when the user has prepared for it (e.g. a dialog close button). + // A breakpoint enables automatic mobile switching; leaving it null keeps switching off. if (mobileBreakpoint != null) { this.mobileBreakpoint = Math.max(mobileBreakpoint, 0); this.mobileModeSwitchingEnabled = true; } - setFabMovable(fabMovable != null ? fabMovable : DEFAULT_FAB_MOVABLE); - setWindowResizable(resizable != null ? resizable : DEFAULT_WINDOW_RESIZABLE); - setFabAnchoredToViewport(fabAnchoredToViewport != null ? fabAnchoredToViewport : DEFAULT_FAB_ANCHORED_TO_VIEWPORT); - setResizeIndicatorsVisible(resizeIndicatorsVisible != null ? resizeIndicatorsVisible : DEFAULT_RESIZE_INDICATORS_VISIBLE); + setFabMovable(fabMovable); + setWindowResizable(resizable); + setFabAnchoredToViewport(fabAnchoredToViewport); + setResizeIndicatorsVisible(resizeIndicatorsVisible); add(chatWindow, fabWrapper, mobileChatWindow); } - /** Parses the given margin value, falling back to {@value #DEFAULT_FAB_MARGIN} when null or invalid. */ - private int parseFabMargin(String fabMargin) { + /** + * Parses the given margin value, falling back to {@value #DEFAULT_FAB_MARGIN} when null, negative + * or invalid. + */ + // Package-private (not private) so unit tests in this package can exercise the parsing fallbacks. + int parseFabMargin(String fabMargin) { if (fabMargin == null) { return DEFAULT_FAB_MARGIN; } try { - return Integer.parseInt(fabMargin.trim().replace("px", "")); + int margin = Integer.parseInt(fabMargin.trim().replace("px", "")); + return margin >= 0 ? margin : DEFAULT_FAB_MARGIN; } catch (NumberFormatException e) { return DEFAULT_FAB_MARGIN; } @@ -451,25 +475,52 @@ private int parseFabMargin(String fabMargin) { @Override protected void onAttach(AttachEvent attachEvent) { super.onAttach(attachEvent); + UI ui = attachEvent.getUI(); + Object registered = ComponentUtil.getData(ui, UI_INSTANCE_KEY); + // Reject a different instance (a second per UI is unsupported); allow this one to re-attach. + if (registered != null && registered != this) { + LOGGER.warn( + "Another ChatAssistant is already attached to this UI; only one is supported per UI." + + " This instance will stay inactive and hidden."); + setVisible(false); + return; + } + setVisible(true); + ComponentUtil.setData(ui, UI_INSTANCE_KEY, this); addComponentRefreshedListener( "fc-chat-assistant-drag-listener", "window.fcChatAssistantMovement($0, $1, $2, $3, $4, $5);", - this.getElement(), fabWrapper.getElement(), fab.getElement(), fabMargin, - DEFAULT_DRAG_SENSITIVITY, fabPosition.name() + this.getElement(), + fabWrapper.getElement(), + fab.getElement(), + fabMargin, + DEFAULT_DRAG_SENSITIVITY, + fabPosition.name()); - ); if (mobileModeSwitchingEnabled) { addComponentRefreshedListener( "fc-chat-assistant-mobile-listener", "window.fcChatAssistantMobileMode($0, $1);", - this.getElement(), mobileBreakpoint - ); + this.getElement(), + mobileBreakpoint); } // (Re)establish any screen-size observers registered before attach (and after a reattach). They // are also re-applied when the popover opens, since the overlay's content is rebuilt each time. screenSizeListeners.keySet().forEach(this::applyScreenSizeListener); } + @Override + protected void onDetach(DetachEvent detachEvent) { + // Release the per-UI slot so the UI can be reused or a replacement instance attached. Only + // clear it + // when it still points at this instance, to avoid clobbering another instance's registration. + UI ui = detachEvent.getUI(); + if (ComponentUtil.getData(ui, UI_INSTANCE_KEY) == this) { + ComponentUtil.setData(ui, UI_INSTANCE_KEY, null); + } + super.onDetach(detachEvent); + } + /** Receives mobile mode changes from the client when the viewport crosses the breakpoint. */ @ClientCallable protected void onMobileModeChange(boolean mobile) { @@ -478,7 +529,10 @@ protected void onMobileModeChange(boolean mobile) { } } - /** Receives chat-window size threshold crossings from the client and dispatches to the matching listener. */ + /** + * Receives chat-window size threshold crossings from the client and dispatches to the matching + * listener. + */ @ClientCallable protected void onScreenSizeChange(int key, boolean matches) { ScreenSizeListenerEntry entry = screenSizeListeners.get(key); @@ -495,39 +549,58 @@ protected void onScreenSizeChange(int key, boolean matches) { protected void onClick() { if (isOpened()) { close(); - } - else { + } else { open(); } } /** Applies common styles to the resizer elements based on the specified direction. */ - protected void applyGenericResizerStyle(Div resizer, String direction) { - resizer.getStyle() + private void applyGenericResizerStyle(Div resizer, String direction) { + resizer + .getStyle() .setPosition(Style.Position.ABSOLUTE) .setDisplay(Style.Display.INLINE_BLOCK) .setZIndex(1001); setResizerClass(resizer, direction, DEFAULT_WINDOW_RESIZABLE); - // A subtle arrowhead pointing in this resizer's drag direction. Hidden until the feature is enabled - // (setResizeIndicatorsVisible) and only shown while the resizer is actually draggable (the resize - // script toggles a class for that); see fc-chat-assistant-style.css. + // A subtle arrowhead pointing in this resizer's drag direction. Hidden until the feature is + // enabled (setResizeIndicatorsVisible) and only shown while the resizer is actually draggable + // (the resize script toggles a class for that); see fc-chat-assistant-style.css. Div arrow = new Div(); arrow.addClassName(DEFAULT_RESIZE_CLASS + "-arrow"); arrow.addClassName(DEFAULT_RESIZE_CLASS + "-arrow-" + direction); resizer.add(arrow); } - protected void setResizerClass(Div resizer, String direction, boolean resizable) { + /** Positions and sizes a resize handle against the edge(s) named by its direction. */ + private void applyResizerEdgeStyle(Div resizer, String direction) { + String edge = DEFAULT_RESIZER_SIZE + "px"; + switch (direction) { + case "top" -> resizer.getStyle().setTop("0").setLeft("0").setHeight(edge).setWidth("100%"); + case "bottom" -> + resizer.getStyle().setBottom("0").setLeft("0").setHeight(edge).setWidth("100%"); + case "right" -> + resizer.getStyle().setTop("0").setRight("0").setHeight("100%").setWidth(edge); + case "left" -> resizer.getStyle().setTop("0").setLeft("0").setHeight("100%").setWidth(edge); + case "top-right" -> + resizer.getStyle().setTop("0").setRight("0").setHeight(edge).setWidth(edge); + case "bottom-right" -> + resizer.getStyle().setBottom("0").setRight("0").setHeight(edge).setWidth(edge); + case "top-left" -> resizer.getStyle().setTop("0").setLeft("0").setHeight(edge).setWidth(edge); + case "bottom-left" -> + resizer.getStyle().setBottom("0").setLeft("0").setHeight(edge).setWidth(edge); + default -> throw new IllegalArgumentException("Unknown resizer direction: " + direction); + } + } + + private void setResizerClass(Div resizer, String direction, boolean resizable) { String classname = DEFAULT_RESIZE_CLASS + "-" + direction; if (resizable && !resizer.getClassNames().contains(classname)) { resizer.addClassName(classname); - } - else if(!resizable && resizer.getClassNames().contains(classname)) { + } else if (!resizable && resizer.getClassNames().contains(classname)) { resizer.removeClassName(classname); } } - /** * Runs the given JavaScript once per component instance, using a flag on the element to avoid * registering duplicate client-side listeners across refreshes. @@ -536,35 +609,64 @@ else if(!resizable && resizer.getClassNames().contains(classname)) { * @param executable the JavaScript to execute * @param parameters parameters for the executable */ - protected void addComponentRefreshedListener(String uniqueFlag, String executable, Serializable... parameters) { - this.getElement().executeJs( - String.format( - """ + protected void addComponentRefreshedListener( + String uniqueFlag, String executable, Serializable... parameters) { + this.getElement() + .executeJs( + String.format( + """ if(!this['%1$s']) { %2$s } if(!this['%1$s']) { this['%1$s'] = '%1$s'; }; - """, uniqueFlag, executable), - parameters - ); + """, + uniqueFlag, executable), + parameters); + } + + /** + * Creates the default chatbot icon. The SVG is read from the classpath and inlined as a data URI, + * so it does not depend on a statically served path: it works both in the demo and when the + * add-on is packaged as a jar, and (unlike a StreamResource) needs no UI/session, keeping the + * icon serializable. + */ + private static SvgIcon createDefaultFabIcon() { + return new SvgIcon(getDefaultFabIconSrc()); } /** - * Creates the default chatbot icon. The SVG is read from the classpath and inlined as a data URI, so - * it does not depend on a statically served path: it works both in the demo and when the add-on is - * packaged as a jar, and (unlike a StreamResource) needs no UI/session, keeping the icon serializable. + * Returns the bundled chatbot data URI, loading it lazily on first use and caching the result. + * Loading is deferred (not done in a static initializer) and degrades gracefully so that a missing + * or unreadable icon resource cannot fail class initialization for callers that supply their own + * FAB icon. */ - protected static SvgIcon createDefaultFabIcon() { - return new SvgIcon(DEFAULT_FAB_ICON_SRC); + private static String getDefaultFabIconSrc() { + String src = defaultFabIconSrc; + if (src == null) { + src = loadDefaultFabIconSrc(); + defaultFabIconSrc = src; + } + return src; } - /** Loads the bundled chatbot SVG from the classpath and encodes it as a data URI. */ + /** + * Loads the bundled chatbot SVG from the classpath and encodes it as a data URI, returning an + * empty string (no icon) if the resource is missing or cannot be read. + */ private static String loadDefaultFabIconSrc() { - try (InputStream in = ChatAssistant.class.getResourceAsStream("/META-INF/resources/icons/chatbot.svg")) { + try (InputStream in = + ChatAssistant.class.getResourceAsStream("/META-INF/resources/icons/chatbot.svg")) { + if (in == null) { + LOGGER.warn( + "Default chatbot icon not found on the classpath" + + " (/META-INF/resources/icons/chatbot.svg); the FAB will have no default icon."); + return ""; + } byte[] svg = in.readAllBytes(); return "data:image/svg+xml;base64," + Base64.getEncoder().encodeToString(svg); - } catch (IOException | NullPointerException e) { - throw new IllegalStateException("Could not load the default chatbot icon", e); + } catch (IOException e) { + LOGGER.warn("Could not load the default chatbot icon; the FAB will have no default icon.", e); + return ""; } } @@ -575,16 +677,12 @@ private static String loadDefaultFabIconSrc() { * @param icon the icon component, it cannot be null */ public void setFabIcon(Component icon) { - Objects.requireNonNull(icon, "Icon cannot be null"); - this.fabIcon = icon instanceof SvgIcon ? (SvgIcon) icon : null; - this.fabIconComponent = icon; - applyIconSize(icon, getFabIconSize()); - fab.setIcon(icon); + setFabIcon(icon, getFabIconSize()); } /** - * Sets the icon for the floating action button with a custom size. The size is capped at the current - * FAB size. + * Sets the icon for the floating action button with a custom size. The size is capped at the + * current FAB size. * * @param icon the icon component, it cannot be null * @param size the icon size in pixels, it must be greater than 0 @@ -594,19 +692,19 @@ public void setFabIcon(Component icon, int size) { if (size <= 0) { throw new IllegalArgumentException("Size must be greater than 0"); } - this.fabIcon = icon instanceof SvgIcon ? (SvgIcon) icon : null; - this.fabIconComponent = icon; - applyIconSize(icon, Math.min(size, fabSize)); + this.fabIcon = icon; fab.setIcon(icon); + applyIconSize(icon, Math.min(size, fabSize)); } /** - * Sets the FAB diameter in pixels, scaling the icon to match. This is the single sizing entry point; - * the theme-variant API uses it to apply the {@link ButtonVariant#LUMO_SMALL}/{@code LUMO_LARGE} sizes. + * Sets the FAB diameter in pixels, scaling the icon to match. This is the single sizing entry + * point; the theme-variant API uses it to apply the {@link FabVariant#SMALL}/{@link + * FabVariant#LARGE} sizes. * * @param size the FAB diameter in pixels, it must be greater than 0 */ - protected void setFabSize(int size) { + private void setFabSize(int size) { if (size <= 0) { throw new IllegalArgumentException("Size must be greater than 0"); } @@ -618,80 +716,94 @@ protected void setFabSize(int size) { .setWidth(size + "px") .setMaxHeight(size + "px") .setMaxWidth(size + "px"); - fabWrapper.getStyle() - .setHeight(size + "px") - .setWidth(size + "px"); - if (fabIconComponent != null) { - applyIconSize(fabIconComponent, getFabIconSize()); + fabWrapper.getStyle().setHeight(size + "px").setWidth(size + "px"); + if (fabIcon != null) { + applyIconSize(fabIcon, getFabIconSize()); } } - /** The icon size that fits the current FAB size. */ + /** The icon size that fits the current FAB size (at least 1px so it is always a valid size). */ private int getFabIconSize() { - return Math.max(0, fabSize - 20); + return Math.max(1, fabSize - 20); } /** - * Pins a deterministic pixel size on the FAB icon, regardless of its concrete component type. Both - * width/height and min/max are set so the size is exact: the default chatbot SVG declares an intrinsic - * size and a {@code vaadin-icon} carries a Lumo {@code em}-based size, either of which would otherwise - * leak through and make the rendered size depend on prior state. + * Pins a deterministic pixel size on the FAB icon, regardless of its concrete component type. + * Both width/height and min/max are set so the size is exact: the default chatbot SVG declares an + * intrinsic size and a {@code vaadin-icon} carries a Lumo {@code em}-based size, either of which + * would otherwise leak through and make the rendered size depend on prior state. */ private void applyIconSize(Component icon, int px) { icon.getStyle() - .setWidth(px + "px").setHeight(px + "px") - .setMinWidth(px + "px").setMinHeight(px + "px") - .setMaxWidth(px + "px").setMaxHeight(px + "px"); - } - - private static boolean isSizeVariant(ButtonVariant variant) { - return variant == ButtonVariant.LUMO_SMALL || variant == ButtonVariant.LUMO_LARGE; + .setWidth(px + "px") + .setHeight(px + "px") + .setMinWidth(px + "px") + .setMinHeight(px + "px") + .setMaxWidth(px + "px") + .setMaxHeight(px + "px"); } /** - * Adds the given theme variants to the FAB. Color and style variants are applied to the underlying - * button; the size variants {@link ButtonVariant#LUMO_SMALL} and {@link ButtonVariant#LUMO_LARGE} - * instead resize the FAB (and its icon) to a predefined diameter. If both size variants are added the - * last one wins. + * Adds the given theme variants to the FAB. Color variants are applied to the underlying button + * and accumulate; the size variants {@link FabVariant#SMALL} and {@link FabVariant#LARGE} instead + * resize the FAB (and its icon) to a predefined diameter and are mutually exclusive, so if both + * are added the last one wins. * + * @since 5.1.0 * @param variants the variants to add */ - public void addFabThemeVariants(ButtonVariant... variants) { - for (ButtonVariant variant : variants) { - if (isSizeVariant(variant)) { + public void addFabThemeVariants(FabVariant... variants) { + for (FabVariant variant : variants) { + if (variant == FabVariant.SMALL || variant == FabVariant.LARGE) { activeSizeVariant = variant; - setFabSize(variant == ButtonVariant.LUMO_LARGE ? DEFAULT_FAB_LARGE_SIZE : DEFAULT_FAB_SMALL_SIZE); + setFabSize(variant == FabVariant.LARGE ? DEFAULT_FAB_LARGE_SIZE : DEFAULT_FAB_SMALL_SIZE); } else { - fab.addThemeVariants(variant); + applyFabColorVariant(variant); } } } /** * Removes the given theme variants from the FAB. Removing the currently active size variant - * ({@link ButtonVariant#LUMO_SMALL}/{@code LUMO_LARGE}) resets the FAB to its default size. + * ({@link FabVariant#SMALL}/{@link FabVariant#LARGE}) resets the FAB to its default size. * + * @since 5.1.0 * @param variants the variants to remove */ - public void removeFabThemeVariants(ButtonVariant... variants) { - for (ButtonVariant variant : variants) { - if (isSizeVariant(variant)) { + public void removeFabThemeVariants(FabVariant... variants) { + for (FabVariant variant : variants) { + if (variant == FabVariant.SMALL || variant == FabVariant.LARGE) { if (variant == activeSizeVariant) { activeSizeVariant = null; setFabSize(DEFAULT_FAB_SIZE); } } else { - fab.removeThemeVariants(variant); + removeFabColorVariant(variant); } } } + private void applyFabColorVariant(FabVariant variant) { + fab.addThemeVariants(variant.getButtonVariant()); + // Aura styles its accent colors via a CSS class rather than the theme attribute, so add it too + // when present; the color then renders under both Lumo and Aura. + if (variant.getAuraClass() != null) { + fab.getElement().getClassList().add(variant.getAuraClass()); + } + } + + private void removeFabColorVariant(FabVariant variant) { + fab.removeThemeVariants(variant.getButtonVariant()); + if (variant.getAuraClass() != null) { + fab.getElement().getClassList().remove(variant.getAuraClass()); + } + } + /** Sets the opened state of the chat window. If true, opens the window; if false, closes it. */ public void setOpened(boolean opened) { - if(opened) { + if (opened) { open(); - } - else { + } else { close(); } } @@ -700,8 +812,7 @@ public void setOpened(boolean opened) { public void open() { if (isMobile()) { mobileChatWindow.open(); - } - else { + } else { chatWindow.open(); } } @@ -710,8 +821,7 @@ public void open() { public void close() { if (isMobile()) { mobileChatWindow.close(); - } - else { + } else { chatWindow.close(); } } @@ -722,39 +832,42 @@ public boolean isOpened() { } /** Returns true if the component is currently in {@link ChatAssistantMode#MOBILE} mode. */ - protected boolean isMobile() { + private boolean isMobile() { return this.mode == ChatAssistantMode.MOBILE; } - /** Sets whether the chat window is resizable. */ + /** + * Sets whether the chat window is resizable. + * + * @since 5.1.0 + * @param resizable whether the chat window can be resized + */ public void setWindowResizable(boolean resizable) { this.resizable = resizable; - if(resizable) { + if (resizable) { overlay.getElement().setAttribute("resizable", true); } else { overlay.getElement().removeAttribute("resizable"); } - setResizerClass(resizerTop, "top", resizable); - setResizerClass(resizerBottom, "bottom", resizable); - setResizerClass(resizerLeft, "left", resizable); - setResizerClass(resizerRight, "right", resizable); - setResizerClass(resizerTopLeft, "top-left", resizable); - setResizerClass(resizerTopRight, "top-right", resizable); - setResizerClass(resizerBottomLeft, "bottom-left", resizable); - setResizerClass(resizerBottomRight, "bottom-right", resizable); + resizers.forEach((direction, resizer) -> setResizerClass(resizer, direction, resizable)); } - /** Returns true if the chat window is resizable, false otherwise. **/ + /** + * Returns whether the chat window is resizable. + * + * @since 5.1.0 + * @return {@code true} if the chat window is resizable + */ public boolean isWindowResizable() { return resizable; } /** * Sets whether a small arrowhead is shown on each resize handle, pointing in that handle's resize - * direction, to hint where the chat window can be dragged. The indicators are subtle (a - * {@code --lumo-contrast-20pct} triangle), hidden by default, and only shown on the handles that can - * currently be dragged given the window's position. + * direction, to hint where the chat window can be dragged. The indicators are subtle, hidden by + * default, and only shown on the handles that can currently be dragged given the window's position. * + * @since 5.1.0 * @param visible whether the resize direction indicators are visible */ public void setResizeIndicatorsVisible(boolean visible) { @@ -762,43 +875,70 @@ public void setResizeIndicatorsVisible(boolean visible) { overlay.getElement().getClassList().set(RESIZE_INDICATOR_VISIBLE_CLASS, visible); } - /** Returns true if the resize direction indicators are visible, false otherwise. */ + /** + * Returns true if the resize direction indicators are visible, false otherwise. + * + * @since 5.1.0 + */ public boolean isResizeIndicatorsVisible() { return resizeIndicatorsVisible; } - /** Sets whether the FAB is movable. **/ + /** + * Sets whether the FAB is movable. In {@link ChatAssistantMode#DESKTOP} mode this also becomes + * the preference restored when returning from {@link ChatAssistantMode#MOBILE} mode (which always + * forces the FAB non-movable). Has no effect while the FAB is not anchored to the viewport (see + * {@link #setFabAnchoredToViewport(boolean)}), since a container-positioned FAB is never draggable. + * + * @since 5.1.0 + * @param movable whether the FAB can be dragged + */ public void setFabMovable(boolean movable) { this.fabMovable = movable; - if(movable) { + // Remember the user's choice as the desktop preference, but not the value forced in mobile mode. + if (!isMobile()) { + this.desktopFabMovablePreference = movable; + } + if (movable) { fab.getElement().setAttribute("movable", true); } else { fab.getElement().removeAttribute("movable"); } } - /** Returns true if the FAB is movable, false otherwise. **/ + /** + * Returns whether the FAB is currently movable. Only a FAB anchored to the viewport can be moved, + * and the FAB is not movable by default while in {@link ChatAssistantMode#MOBILE} mode. + * + * @since 5.1.0 + * @return {@code true} if the FAB is currently movable + */ public boolean isFabMovable() { - return fabMovable; + return fabMovable && fabAnchoredToViewport; } /** - * Sets whether the FAB is anchored to the viewport. When {@code true} (the default) the FAB floats - * over the viewport; when {@code false} it is positioned within its container, so it can be placed - * inside a bounded element. A FAB that is not anchored to the viewport is not movable. + * Sets whether the FAB is anchored to the viewport. When {@code true} (the default) the FAB + * floats over the viewport; when {@code false} it is positioned within its container, so it can + * be placed inside a bounded element. A FAB that is not anchored to the viewport is not movable. + * + * @since 5.1.0 */ public void setFabAnchoredToViewport(boolean anchoredToViewport) { this.fabAnchoredToViewport = anchoredToViewport; fabWrapper.getStyle().setPosition(anchoredToViewport ? Position.FIXED : Position.ABSOLUTE); - if(anchoredToViewport) { + if (anchoredToViewport) { fab.getElement().setAttribute("anchored", true); - } - else { + } else { fab.getElement().removeAttribute("anchored"); } } - /** Returns true if the FAB is anchored to the viewport, false if positioned within its container. **/ + /** + * Returns true if the FAB is anchored to the viewport, false if positioned within its container. + * + * @since 5.1.0 + */ public boolean isFabAnchoredToViewport() { return fabAnchoredToViewport; } @@ -807,6 +947,7 @@ public boolean isFabAnchoredToViewport() { * Moves the FAB to the given corner. This also becomes the position the FAB returns to when * {@link #resetFabPosition()} is called. * + * @since 5.1.0 * @param fabPosition the corner to move the FAB to, it cannot be null */ public void setFabPosition(FabPosition fabPosition) { @@ -815,16 +956,27 @@ public void setFabPosition(FabPosition fabPosition) { resetFabPosition(); } - /** Returns the FAB's configured corner. */ + /** + * Returns the FAB's configured corner. + * + * @since 5.1.0 + */ public FabPosition getFabPosition() { return fabPosition; } - /** Moves the FAB back to its configured corner. */ + /** + * Moves the FAB back to its configured corner. + * + * @since 5.1.0 + */ public void resetFabPosition() { - this.getElement().executeJs( - "window.fcChatAssistantResetPosition($0, $1, $2);", - fabWrapper.getElement(), fabMargin, fabPosition.name()); + this.getElement() + .executeJs( + "window.fcChatAssistantResetPosition($0, $1, $2);", + fabWrapper.getElement(), + fabMargin, + fabPosition.name()); } /** @@ -833,30 +985,29 @@ public void resetFabPosition() { * @param minWidth the minimum width as a CSS length (e.g. "150px") */ public void setWindowMinWidth(String minWidth) { - this.minWidth = minWidth; - this.overlay.getStyle().set("--fc-min-width", minWidth); + this.overlay.getStyle().set(CSS_MIN_WIDTH, minWidth); applyWindowConstraints(); } /** * Sets the chat window minimum width, the lower bound enforced while resizing. * + * @since 5.1.0 * @param minWidth the minimum width in px (e.g. 150) */ public void setWindowMinWidth(int minWidth) { - this.minWidth = String.valueOf(minWidth) + "px"; - this.overlay.getStyle().set("--fc-min-width", this.minWidth); + this.overlay.getStyle().set(CSS_MIN_WIDTH, minWidth + "px"); applyWindowConstraints(); } /** * Sets the chat window minimum height, the lower bound enforced while resizing. * + * @since 5.1.0 * @param minHeight the minimum height in px (e.g. 150) */ public void setWindowMinHeight(int minHeight) { - this.minHeight = String.valueOf(minHeight) + "px"; - this.overlay.getStyle().set("--fc-min-height", this.minHeight); + this.overlay.getStyle().set(CSS_MIN_HEIGHT, minHeight + "px"); applyWindowConstraints(); } @@ -866,8 +1017,7 @@ public void setWindowMinHeight(int minHeight) { * @param minHeight the minimum height as a CSS length (e.g. "150px") */ public void setWindowMinHeight(String minHeight) { - this.minHeight = minHeight; - this.overlay.getStyle().set("--fc-min-height", minHeight); + this.overlay.getStyle().set(CSS_MIN_HEIGHT, minHeight); applyWindowConstraints(); } @@ -877,17 +1027,18 @@ public void setWindowMinHeight(String minHeight) { * @param maxWidth the maximum width as a CSS length */ public void setWindowMaxWidth(String maxWidth) { - this.overlay.getStyle().set("--fc-max-width", maxWidth); + this.overlay.getStyle().set(CSS_MAX_WIDTH, maxWidth); applyWindowConstraints(); } /** * Sets the chat window maximum width, the upper bound enforced while resizing. * + * @since 5.1.0 * @param maxWidth the maximum width in px (e.g. 150) */ public void setWindowMaxWidth(int maxWidth) { - this.overlay.getStyle().set("--fc-max-width", String.valueOf(maxWidth) + "px"); + this.overlay.getStyle().set(CSS_MAX_WIDTH, maxWidth + "px"); applyWindowConstraints(); } @@ -897,73 +1048,87 @@ public void setWindowMaxWidth(int maxWidth) { * @param maxHeight the maximum height as a CSS length */ public void setWindowMaxHeight(String maxHeight) { - this.overlay.getStyle().set("--fc-max-height", maxHeight); + this.overlay.getStyle().set(CSS_MAX_HEIGHT, maxHeight); applyWindowConstraints(); } /** * Sets the chat window maximum height, the upper bound enforced while resizing. * + * @since 5.1.0 * @param maxHeight the maximum height in px (e.g. 150) */ public void setWindowMaxHeight(int maxHeight) { - this.overlay.getStyle().set("--fc-max-height", String.valueOf(maxHeight) + "px"); + this.overlay.getStyle().set(CSS_MAX_HEIGHT, maxHeight + "px"); applyWindowConstraints(); } /** - * Sets the chat window's initial height. Use absolute units (e.g. "400px"). + * Sets the chat window's initial height. Prefer absolute or viewport-relative units (e.g. "400px", + * "60vh"); a percentage resolves against the popover, not the viewport, and may not behave as + * expected. * - * @param height the height as an absolute CSS length + * @param height the height as a CSS length */ public void setWindowHeight(String height) { applyWindowSize("height", height); } - + /** * Sets the chat window's initial height. Use absolute units (e.g. 400). * + * @since 5.1.0 * @param height the height in px (e.g. 400) */ public void setWindowHeight(int height) { - applyWindowSize("height", String.valueOf(height) + "px"); + applyWindowSize("height", height + "px"); } /** - * Sets the chat window's initial width. Use absolute units (e.g. "400px"). + * Sets the chat window's initial width. Prefer absolute or viewport-relative units (e.g. "400px", + * "40vw"); a percentage resolves against the popover, not the viewport, and may not behave as + * expected. * - * @param width the width as an absolute CSS length + * @param width the width as a CSS length */ public void setWindowWidth(String width) { applyWindowSize("width", width); } - + /** * Sets the chat window's initial width. Use absolute units (e.g. 400). * + * @since 5.1.0 * @param width the width in px (e.g. 400) */ public void setWindowWidth(int width) { - applyWindowSize("width", String.valueOf(width) + "px"); + applyWindowSize("width", width + "px"); } /** Sizes the popover content part (works on Vaadin 24 and 25). */ private void applyWindowSize(String dimension, String value) { - if (value != null) { - this.getElement().executeJs( - "window.fcChatAssistantSetWindowSize($0, $1, $2, $3);", - overlay, DEFAULT_POPOVER_TAG, dimension, value - ); + if (value == null) { + return; } + // Persist the desired size on the durable overlay Div (as a custom property that survives + // close/reopen); the actual content part is sized by applyWindowConstraints on open. + overlay.getStyle().set("height".equals(dimension) ? CSS_HEIGHT : CSS_WIDTH, value); + applyWindowConstraints(); } /** - * Re-applies the configured size and min/max bounds to the popover content part. Safe to call while the - * window is closed (it no-ops until the content part exists, then applies on the next open). + * Re-applies the configured size and min/max bounds to the popover content part, reading them from + * the {@code --fc-*} custom properties on the durable overlay Div. Only pushes to the client while + * the popover is open; when it is closed the properties are applied on the next open (see the + * open listener in {@link #initializeChatWindow()}), so calling this at construction time does not + * spin a client-side poll against a content part that does not exist yet. */ private void applyWindowConstraints() { - this.getElement().executeJs( - "window.fcChatAssistantApplyConstraints($0, $1);", overlay, DEFAULT_POPOVER_TAG); + if (!chatWindow.isOpened()) { + return; + } + this.getElement() + .executeJs("window.fcChatAssistantApplyConstraints($0, $1);", overlay, DEFAULT_POPOVER_TAG); } protected void initializeHeader() { @@ -979,14 +1144,22 @@ protected void initializeHeader() { @SuppressWarnings("unchecked") protected void initializeFooter() { this.messageInput = new MessageInput(); - this.messageInput.getStyle() - .setMaxHeight("80px") - .set("width", "100%") - .setPadding("0 2px"); // Account for border when focused (it will get cropped otherwise) - - this.defaultSubmitListenerRegistration = this.messageInput.addSubmitListener((se) -> this.sendMessage( - (T) Message.builder().messageTime( - LocalDateTime.now()).name("User").content(se.getValue()).build())); + this.messageInput + .getStyle() + .setMaxHeight("80px") + .set("width", "100%") + .setPadding("0 2px"); // Account for border when focused (it will get cropped otherwise) + + this.defaultSubmitListenerRegistration = + this.messageInput.addSubmitListener( + (se) -> + this.sendMessage( + (T) + Message.builder() + .messageTime(LocalDateTime.now()) + .name("User") + .content(se.getValue()) + .build())); this.whoIsTyping = new Span(); this.whoIsTyping.setClassName("chat-assistant-who-is-typing"); this.whoIsTyping.setVisible(false); @@ -1002,97 +1175,63 @@ protected void initializeFooter() { @SuppressWarnings("unchecked") protected void initializeContent(boolean markdownEnabled) { - this.content.setRenderer(new ComponentRenderer<>( - message -> new ChatMessage<>(message, markdownEnabled), - (component, message) -> { - ((ChatMessage) component).setMessage(message); - return component; - }) - ); + this.content.setRenderer( + new ComponentRenderer<>( + message -> new ChatMessage<>(message, markdownEnabled), + (component, message) -> { + ((ChatMessage) component).setMessage(message); + return component; + })); this.content.setItems(this.messages); // Allow the content to shrink below its intrinsic size so the popover clamp produces an internal // scroll instead of overflowing (the standard flexbox min-content fix). - this.content.getStyle() - .set("flex", "1") - .setMinHeight("0"); + this.content.getStyle().set("flex", "1").setMinHeight("0"); this.container.add(this.headerComponent, this.content, this.footerContainer); this.container.setPadding(true); this.container.setMargin(false); this.container.setSpacing(false); this.container.setSizeFull(); - this.container.getStyle() - .set("flex", "1") - .setHeight(null) - // Allow the column to shrink below its min-content height so the configured/min window height wins - // and the message list scrolls internally instead of forcing the window taller. - .set("min-height", "0") - .setAlignItems(AlignItems.STRETCH) - .setAlignSelf(AlignSelf.STRETCH); + this.container + .getStyle() + .set("flex", "1") + .setHeight(null) + // Allow the column to shrink below its min-content height so the configured/min window + // height wins and the message list scrolls internally instead of forcing the window taller. + .set("min-height", "0") + .setAlignItems(AlignItems.STRETCH) + .setAlignSelf(AlignSelf.STRETCH); } protected void initializeChatWindow() { this.chatWindow.setOpenOnClick(false); this.chatWindow.setCloseOnOutsideClick(false); - this.chatWindow.addOpenedChangeListener(ev -> { - if (ev.isOpened()) { - // The overlay (and its content part) is recreated on each open, so re-apply the last size - // (the configured initial size or whatever the user resized to), stored on the overlay Div. - this.getElement().executeJs( - "window.fcChatAssistantRestoreWindowSize($0, $1);", overlay, DEFAULT_POPOVER_TAG); - // Re-establish chat-window size observers against the freshly laid-out overlay and re-deliver - // the current state for this open. - screenSizeListeners.keySet().forEach(this::applyScreenSizeListener); - addComponentRefreshedListener( - "fc-chat-assistant-resize-top-listener", - "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'top');", - resizerTop.getElement(), overlay, - DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE - - ); - addComponentRefreshedListener( - "fc-chat-assistant-resize-bottom-right-listener", - "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'bottom-right');", - resizerBottomRight.getElement(), overlay, - DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE - ); - addComponentRefreshedListener( - "fc-chat-assistant-resize-top-right-listener", - "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'top-right');", - resizerTopRight.getElement(), overlay, - DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE - ); - addComponentRefreshedListener( - "fc-chat-assistant-resize-right-listener", - "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'right');", - resizerRight.getElement(), overlay, - DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE - ); - addComponentRefreshedListener( - "fc-chat-assistant-resize-bottom-listener", - "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'bottom');", - resizerBottom.getElement(), overlay, - DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE - ); - addComponentRefreshedListener( - "fc-chat-assistant-resize-left-listener", - "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'left');", - resizerLeft.getElement(), overlay, - DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE - ); - addComponentRefreshedListener( - "fc-chat-assistant-resize-top-left-listener", - "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'top-left');", - resizerTopLeft.getElement(), overlay, - DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE - ); - addComponentRefreshedListener( - "fc-chat-assistant-resize-bottom-left-listener", - "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'bottom-left');", - resizerBottomLeft.getElement(), overlay, - DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE - ); - } - }); + this.chatWindow.addOpenedChangeListener( + ev -> { + if (ev.isOpened()) { + // The overlay (and its content part) is recreated on each open, so re-apply the last + // size (the configured initial size or whatever the user resized to), stored on the + // overlay Div. + this.getElement() + .executeJs( + "window.fcChatAssistantRestoreWindowSize($0, $1);", + overlay, + DEFAULT_POPOVER_TAG); + // Re-establish the chat-window size observers against the freshly laid-out overlay and + // re-deliver the current state for this open. + screenSizeListeners.keySet().forEach(this::applyScreenSizeListener); + resizers.forEach( + (direction, resizer) -> + addComponentRefreshedListener( + "fc-chat-assistant-resize-" + direction + "-listener", + "window.fcChatAssistantResize($0, $1, $2, $3, $4, $5, '" + direction + "');", + this.getElement(), + resizer.getElement(), + overlay, + DEFAULT_POPOVER_TAG, + DEFAULT_RESIZER_SIZE, + DEFAULT_MAX_RESIZER_SIZE)); + } + }); } public void setDataProvider(DataProvider dataProvider) { @@ -1100,7 +1239,8 @@ public void setDataProvider(DataProvider dataProvider) { } /** - * Uses the provided string as the text shown over the message input to indicate that someone is typing. + * Uses the provided string as the text shown over the message input to indicate that someone is + * typing. * * @param whoIsTyping string to be shown as an indication of someone typing */ @@ -1118,22 +1258,21 @@ public String getWhoIsTyping() { return whoIsTyping.getText(); } - /** - * Clears the text shown over the message input to indicate that someone is typing. - */ + /** Clears the text shown over the message input to indicate that someone is typing. */ public void clearWhoIsTyping() { this.whoIsTyping.setText(null); this.whoIsTyping.setVisible(false); } /** - * Sets the SubmitListener that will be notified when the user submits a message on the underlying messageInput. + * Sets the SubmitListener that will be notified when the user submits a message on the underlying + * messageInput. * * @param listener the listener that will be notified when the SubmitEvent is fired * @return registration for removal of the listener */ public Registration setSubmitListener(ComponentEventListener listener) { - if(this.defaultSubmitListenerRegistration != null) { + if (this.defaultSubmitListenerRegistration != null) { this.defaultSubmitListenerRegistration.remove(); } this.defaultSubmitListenerRegistration = this.messageInput.addSubmitListener(listener); @@ -1233,29 +1372,23 @@ public Component getFooterComponent() { } /** - * Scrolls to the given position. Scrolls so that the element is shown at - * the start of the visible area whenever possible. - *

- * If the index parameter exceeds current item set size the grid will scroll - * to the end. + * Scrolls to the given position. Scrolls so that the element is shown at the start of the visible + * area whenever possible. * - * @param position - * zero based index of the item to scroll to in the current view. + *

If the index parameter exceeds current item set size the grid will scroll to the end. + * + * @param position zero based index of the item to scroll to in the current view. */ public void scrollToIndex(int position) { this.content.scrollToIndex(position); } - /** - * Scrolls to the first element. - */ + /** Scrolls to the first element. */ public void scrollToStart() { this.content.scrollToStart(); } - /** - * Scrolls to the last element of the list. - */ + /** Scrolls to the last element of the list. */ public void scrollToEnd() { this.content.scrollToEnd(); } @@ -1296,54 +1429,54 @@ public int getUnreadMessages() { } /** - * Sets the number of unread messages shown on the FAB badge. The value is clamped to the 0–99 - * range; the badge is hidden when it is 0. + * Sets the number of unread messages shown on the FAB badge. The value is clamped to the + * 0–99 range; the badge is hidden when it is 0. * * @param unreadMessages the number of unread messages to set */ public void setUnreadMessages(int unreadMessages) { this.unreadMessages = unreadMessages >= 0 ? Math.min(unreadMessages, 99) : 0; unreadBadge.setText(String.valueOf(this.unreadMessages)); - if(this.unreadMessages > 0) { + if (this.unreadMessages > 0) { unreadBadge.getStyle().setScale("1"); - } - else { + } else { unreadBadge.getStyle().setScale("0"); } } /** - * Sets the background and text color of the unread badge. If null or empty, the default values are used. + * Sets the background and text color of the unread badge. If null or empty, the default values + * are used. * + * @since 5.1.0 * @param background the background color of the unread badge * @param color the text color of the unread badge */ public void setUnreadBadgeColors(String background, String color) { - if(background != null && !background.isBlank()) { + if (background != null && !background.isBlank()) { unreadBadge.getStyle().set("background-color", background); - } - else { + } else { unreadBadge.getStyle().set("background-color", DEFAULT_UNREAD_BADGE_BACKGROUND); } - if(color != null && !color.isEmpty()) { + if (color != null && !color.isBlank()) { unreadBadge.getStyle().set("color", color); - } - else { + } else { unreadBadge.getStyle().set("color", DEFAULT_UNREAD_BADGE_COLOR); } } /** - * Sets the display mode programmatically. In {@link ChatAssistantMode#MOBILE} mode the chat window - * opens as a full-screen dialog and the FAB is not movable (dragging would compete with touch - * scrolling, and the full-screen dialog already covers the viewport); the desktop movable preference - * is preserved and restored when switching back. In {@link ChatAssistantMode#DESKTOP} mode the window - * opens as an anchored popover. - *

- * When automatic switching is enabled (see {@link #setMobileModeSwitchingEnabled(boolean)}), this - * value may be overridden the next time the viewport crosses the configured breakpoint. To keep - * full manual control, disable automatic switching first. + * Sets the display mode programmatically. In {@link ChatAssistantMode#MOBILE} mode the chat + * window opens as a full-screen dialog and the FAB is not movable (dragging would compete with + * touch scrolling, and the full-screen dialog already covers the viewport); the desktop movable + * preference is preserved and restored when switching back. In {@link ChatAssistantMode#DESKTOP} + * mode the window opens as an anchored popover. * + *

When automatic switching is enabled (see {@link #setMobileModeSwitchingEnabled(boolean)}), + * this value may be overridden the next time the viewport crosses the configured breakpoint. To + * keep full manual control, disable automatic switching first. + * + * @since 5.1.0 * @param mode the mode to switch to, it cannot be null */ public void setMode(ChatAssistantMode mode) { @@ -1351,14 +1484,18 @@ public void setMode(ChatAssistantMode mode) { setMode(mode, false); } - /** Returns the current display mode. */ + /** + * Returns the current display mode. + * + * @since 5.1.0 + */ public ChatAssistantMode getMode() { return mode; } /** - * Applies the given mode, reconciling the active surface and open state, and fires a - * {@link ModeChangedEvent} when the mode actually changes. + * Applies the given mode, reconciling the active surface and open state, and fires a {@link + * ModeChangedEvent} when the mode actually changes. * * @param mode the mode to switch to * @param fromClient whether the change originated from a client-side breakpoint crossing @@ -1377,10 +1514,10 @@ protected void setMode(ChatAssistantMode mode, boolean fromClient) { overlay.remove(container); mobileChatWindow.add(container); } - // Preserve the user's movable preference; mobile mode always disables dragging. - boolean movablePreference = this.fabMovable; + // Mobile mode always disables dragging. The desktop preference is retained in + // desktopFabMovablePreference (setFabMovable does not overwrite it while in mobile mode) and + // restored when leaving mobile, so isFabMovable() honestly reports false here. setFabMovable(false); - this.fabMovable = movablePreference; // The popover would otherwise stay open with no content; move the open state to the dialog. if (wasOpened) { chatWindow.close(); @@ -1391,7 +1528,7 @@ protected void setMode(ChatAssistantMode mode, boolean fromClient) { mobileChatWindow.remove(container); overlay.add(container); } - setFabMovable(this.fabMovable); + setFabMovable(this.desktopFabMovablePreference); // Move the open state from the dialog back to the popover. if (wasOpened) { mobileChatWindow.close(); @@ -1404,24 +1541,31 @@ protected void setMode(ChatAssistantMode mode, boolean fromClient) { } /** - * Sets the mobile mode programmatically. Convenience wrapper over {@link #setMode(ChatAssistantMode)}. + * Sets the mobile mode programmatically. Convenience wrapper over {@link + * #setMode(ChatAssistantMode)}. * - * @param mobileMode {@code true} for {@link ChatAssistantMode#MOBILE}, {@code false} for - * {@link ChatAssistantMode#DESKTOP} + * @since 5.1.0 + * @param mobileMode {@code true} for {@link ChatAssistantMode#MOBILE}, {@code false} for {@link + * ChatAssistantMode#DESKTOP} */ public void setMobileMode(boolean mobileMode) { setMode(mobileMode ? ChatAssistantMode.MOBILE : ChatAssistantMode.DESKTOP); } - /** Returns true if the component is currently in {@link ChatAssistantMode#MOBILE} mode. */ + /** + * Returns true if the component is currently in {@link ChatAssistantMode#MOBILE} mode. + * + * @since 5.1.0 + */ public boolean isMobileMode() { return isMobile(); } /** - * Adds a listener that is notified whenever the component switches between - * {@link ChatAssistantMode#MOBILE} and {@link ChatAssistantMode#DESKTOP} mode. + * Adds a listener that is notified whenever the component switches between {@link + * ChatAssistantMode#MOBILE} and {@link ChatAssistantMode#DESKTOP} mode. * + * @since 5.1.0 * @param listener the listener to add; it receives the mode the component switched to * @return a registration for removing the listener */ @@ -1429,12 +1573,17 @@ public Registration addModeChangedListener(ComponentEventListener> { private final ChatAssistantMode mode; - protected ModeChangedEvent(ChatAssistant source, boolean fromClient, ChatAssistantMode mode) { + protected ModeChangedEvent( + ChatAssistant source, boolean fromClient, ChatAssistantMode mode) { super(source, fromClient); this.mode = mode; } @@ -1446,24 +1595,26 @@ public ChatAssistantMode getMode() { } /** - * Adds a listener that is notified when the chat window's own size crosses the given threshold. At - * least one of {@code width}/{@code height} must be non-null; a {@code null} axis is not tracked. - * When both are given, the listener fires only when both are simultaneously satisfied (AND). - *

- * The chat window only has a size while it is open, so the listener observes size changes (drag - * resize, {@link #setWindowWidth}/{@link #setWindowHeight}, or viewport clamping) while open. On each - * open it is invoked once with the current state, then only when the size crosses the threshold. The - * threshold is inclusive: a window exactly at the threshold counts as above it - * ({@link ScreenSizeEvent#isAboveThreshold()} is {@code true}). Each listener only receives events - * for its own threshold. + * Adds a listener that is notified when the chat window's own size crosses the given threshold. + * At least one of {@code width}/{@code height} must be non-null; a {@code null} axis is not + * tracked. When both are given, the listener fires only when both are simultaneously satisfied + * (AND). + * + *

The chat window only has a size while it is open, so the listener observes size changes + * (drag resize, {@link #setWindowWidth}/{@link #setWindowHeight}, or viewport clamping) while + * open. On each open it is invoked once with the current state, then only when the size crosses + * the threshold. The threshold is inclusive: a window exactly at the threshold counts as above it + * ({@link ScreenSizeEvent#isAboveThreshold()} is {@code true}). Each listener only receives + * events for its own threshold. * + * @since 5.1.0 * @param width the width threshold in pixels, or {@code null} to ignore width * @param height the height threshold in pixels, or {@code null} to ignore height * @param listener the listener to add * @return a registration for removing the listener */ - public Registration addScreenSizeListener(Integer width, Integer height, - ComponentEventListener listener) { + public Registration addScreenSizeListener( + Integer width, Integer height, ComponentEventListener listener) { Objects.requireNonNull(listener, "Listener cannot be null"); if (width == null && height == null) { throw new IllegalArgumentException("At least one of width or height must be provided"); @@ -1480,23 +1631,34 @@ public Registration addScreenSizeListener(Integer width, Integer height, return () -> { screenSizeListeners.remove(key); - this.getElement().executeJs("window.fcChatAssistantScreenSizeOff?.($0, $1);", this.getElement(), key); + this.getElement() + .executeJs("window.fcChatAssistantScreenSizeOff?.($0, $1);", this.getElement(), key); }; } /** * (Re)registers a single chat-window size observer on the client. Called on attach and on each - * popover open (the overlay content is rebuilt each open); the JS replaces any existing observer for - * the key and re-delivers the current state, so it is safe to call repeatedly. + * popover/dialog open (the surface content is rebuilt each open); the JS replaces any existing + * observer for the key and re-delivers the current state, so it is safe to call repeatedly. + * + *

The observed element is {@link #container}, the content surface that is moved between the + * desktop popover ({@link #overlay}) and the mobile dialog ({@link #mobileChatWindow}). Observing + * it (rather than {@code overlay}) keeps the listener working in both modes: the mobile dialog + * host has no size of its own, and a {@code ResizeObserver} follows the element across DOM moves. */ private void applyScreenSizeListener(Integer key) { ScreenSizeListenerEntry entry = screenSizeListeners.get(key); if (entry == null) { return; } - this.getElement().executeJs( - "window.fcChatAssistantScreenSize($0, $1, $2, $3, $4);", - this.getElement(), overlay, key, entry.width, entry.height); + this.getElement() + .executeJs( + "window.fcChatAssistantScreenSize($0, $1, $2, $3, $4);", + this.getElement(), + container, + key, + entry.width, + entry.height); } /** Per-key bookkeeping for a screen-size listener: its thresholds and the listener to invoke. */ @@ -1505,15 +1667,19 @@ private static final class ScreenSizeListenerEntry implements Serializable { private final Integer height; private final ComponentEventListener listener; - ScreenSizeListenerEntry(Integer width, Integer height, - ComponentEventListener listener) { + ScreenSizeListenerEntry( + Integer width, Integer height, ComponentEventListener listener) { this.width = width; this.height = height; this.listener = listener; } } - /** Direction in which the chat window crossed a configured threshold. */ + /** + * Direction in which the chat window crossed a configured threshold. + * + * @since 5.1.0 + */ public enum ScreenSizeDirection { /** The chat window grew to or past the threshold (it is now at or above it). */ INCREASED, @@ -1522,9 +1688,11 @@ public enum ScreenSizeDirection { } /** - * Event fired when the chat window's size crosses a width and/or height threshold registered through - * {@link #addScreenSizeListener(Integer, Integer, ComponentEventListener)}. It reports the crossing - * direction and the configured threshold(s); no live size is exposed. + * Event fired when the chat window's size crosses a width and/or height threshold registered + * through {@link #addScreenSizeListener(Integer, Integer, ComponentEventListener)}. It reports + * the crossing direction and the configured threshold(s); no live size is exposed. + * + * @since 5.1.0 */ public static class ScreenSizeEvent extends ComponentEvent> { @@ -1532,8 +1700,12 @@ public static class ScreenSizeEvent extends ComponentEvent> { private final Integer heightThreshold; private final boolean aboveThreshold; - protected ScreenSizeEvent(ChatAssistant source, boolean fromClient, Integer widthThreshold, - Integer heightThreshold, boolean aboveThreshold) { + protected ScreenSizeEvent( + ChatAssistant source, + boolean fromClient, + Integer widthThreshold, + Integer heightThreshold, + boolean aboveThreshold) { super(source, fromClient); this.widthThreshold = widthThreshold; this.heightThreshold = heightThreshold; @@ -1565,8 +1737,10 @@ public ScreenSizeDirection getDirection() { } /** - * Returns the maximum screen width, in pixels, below which mobile mode is activated automatically. + * Returns the maximum screen width, in pixels, below which mobile mode is activated + * automatically. * + * @since 5.1.0 * @return the breakpoint in pixels */ public int getMobileBreakpoint() { @@ -1575,16 +1749,18 @@ public int getMobileBreakpoint() { /** * Enables or disables automatic switching between mobile and desktop mode based on the configured - * breakpoint. Automatic switching is disabled by default; it is enabled either by defining a - * breakpoint in the constructor or by calling this method with {@code true}. Enable it only after - * preparing the mobile experience (e.g. providing a way to close the full-screen dialog). - *

- * When disabled, the component is left in whatever mode it is currently in (freeze), and the mode - * can only be changed manually via {@link #setMode(ChatAssistantMode)}. When enabled, the - * breakpoint is evaluated against the current viewport width. If no breakpoint was configured, the - * default ({@value #DEFAULT_MOBILE_BREAKPOINT}px) is used. + * breakpoint. Automatic switching is disabled by default; it is enabled either by defining + * a breakpoint in the constructor or by calling this method with {@code true}. Enable it only + * after preparing the mobile experience (e.g. providing a way to close the full-screen dialog). + * + *

When disabled, the component is left in whatever mode it is currently in (freeze), and the + * mode can only be changed manually via {@link #setMode(ChatAssistantMode)}. When enabled, the + * breakpoint is evaluated against the current viewport width. If no breakpoint was configured, + * the default ({@value #DEFAULT_MOBILE_BREAKPOINT}px) is used. * - * @param enabled {@code true} to switch automatically on viewport changes, {@code false} to freeze + * @since 5.1.0 + * @param enabled {@code true} to switch automatically on viewport changes, {@code false} to + * freeze */ public void setMobileModeSwitchingEnabled(boolean enabled) { if (this.mobileModeSwitchingEnabled == enabled) { @@ -1592,8 +1768,11 @@ public void setMobileModeSwitchingEnabled(boolean enabled) { } this.mobileModeSwitchingEnabled = enabled; if (enabled) { - this.getElement().executeJs( - "window.fcChatAssistantMobileMode($0, $1);", this.getElement(), this.mobileBreakpoint); + this.getElement() + .executeJs( + "window.fcChatAssistantMobileMode($0, $1);", + this.getElement(), + this.mobileBreakpoint); } else { this.getElement().executeJs("window.fcChatAssistantMobileModeOff($0);", this.getElement()); } @@ -1602,6 +1781,7 @@ public void setMobileModeSwitchingEnabled(boolean enabled) { /** * Returns whether automatic switching between mobile and desktop mode is enabled. * + * @since 5.1.0 * @return {@code true} if automatic switching is enabled */ public boolean isMobileModeSwitchingEnabled() { diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java index f583fa0..9370cd6 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,7 +27,6 @@ import com.vaadin.flow.component.dependency.JsModule; import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.markdown.Markdown; - import java.time.format.DateTimeFormatter; import lombok.EqualsAndHashCode; @@ -39,37 +38,39 @@ @JsModule("@vaadin/message-list/src/vaadin-message.js") @Tag("vaadin-message") @CssImport("./styles/fc-chat-message-styles.css") -@EqualsAndHashCode(callSuper=false) +@EqualsAndHashCode(callSuper = false) public class ChatMessage extends Component implements HasComponents { - + private T message; private boolean markdownEnabled; private Div loader; private Markdown markdown; - + private static final String DEFAULT_MARKDOWN_CLASS = "fc-chat-message-markdown"; + /** * Creates a new ChatMessage based on the supplied message without markdown support. - * + * * @param message message used to populate the ChatMessage instance */ public ChatMessage(T message) { this(message, false); } - + /** * Creates a new ChatMessage based on the supplied message. - * + * * @param message message used to populate the ChatMessage instance * @param markdownEnabled whether the message supports markdown or not */ public ChatMessage(T message, boolean markdownEnabled) { this.markdownEnabled = markdownEnabled; - loader = new Div(new Div(),new Div(), new Div(), new Div()); + loader = new Div(new Div(), new Div(), new Div(), new Div()); loader.setClassName("lds-ellipsis"); loader.setVisible(false); this.add(loader); if (markdownEnabled) { markdown = new Markdown(message.getContent()); + markdown.setClassName(DEFAULT_MARKDOWN_CLASS); this.add(markdown); } setMessage(message); @@ -77,62 +78,65 @@ public ChatMessage(T message, boolean markdownEnabled) { /** * Updates the component by setting the current underlying message. - * + * * @param message message used to populate the ChatMessage instance */ public void setMessage(T message) { this.message = message; updateMessage(message); - if (message.getName()!=null) { + if (message.getName() != null) { this.setUserName(message.getName()); - if (message.getAvatar()!=null) { + if (message.getAvatar() != null) { this.setUserImg(message.getAvatar()); } } - if (message.getMessageTime()!=null) { - String formattedTime = message.getMessageTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); + if (message.getMessageTime() != null) { + String formattedTime = + message.getMessageTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); this.setTime(formattedTime); } } /** * Updates the displayed message content and loading state. - * @param message + * + * @param message the message whose content and loading state to render */ private void updateMessage(T message) { loader.setVisible(message.isLoading()); - if (!message.isLoading()) { - if (markdownEnabled) { - markdown.setContent(message.getContent()); - } else { - // Strip any stale text node left in the slot, then append the current content as fresh text. - this.getElement().executeJs( - "[...this.childNodes].forEach(node => node.nodeType === 3 && this.removeChild(node));" - + "this.appendChild(document.createTextNode($0));", - message.getContent()); - } + // Clear the content while loading so a recycled VirtualList row does not keep showing the + // previous message's text/markdown next to the spinner. + String content = message.isLoading() ? "" : message.getContent(); + if (markdownEnabled) { + markdown.setContent(content); + } else { + // Strip any stale text node left in the slot, then append the current content as fresh text. + this.getElement() + .executeJs( + "[...this.childNodes].forEach(node => node.nodeType === 3 && this.removeChild(node));" + + "if ($0) this.appendChild(document.createTextNode($0));", + content); } } - + /** * Returns the underlying message. - * + * * @return the message object used to populate this ChatMessage */ public T getMessage() { return message; } - + private void setUserName(String username) { getElement().setAttribute("user-name", username); } - + private void setUserImg(String imageUrl) { getElement().setAttribute("user-img", imageUrl); } - + private void setTime(String time) { getElement().setAttribute("time", time); } - } diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/ChatAssistantMode.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/ChatAssistantMode.java index 46daf48..a047001 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/ChatAssistantMode.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/ChatAssistantMode.java @@ -2,14 +2,14 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -22,6 +22,8 @@ /** * The display mode of the chat assistant: {@link #MOBILE} opens the chat window as a full-screen * dialog, while {@link #DESKTOP} opens it as an anchored popover. + * + * @since 5.1.0 */ public enum ChatAssistantMode { MOBILE, diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabPosition.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabPosition.java index d04e926..523aff8 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabPosition.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabPosition.java @@ -2,14 +2,14 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -19,7 +19,11 @@ */ package com.flowingcode.vaadin.addons.chatassistant.model; -/** The corner of the viewport where the floating action button is initially placed. */ +/** + * The corner of the viewport where the floating action button is initially placed. + * + * @since 5.1.0 + */ public enum FabPosition { BOTTOM_RIGHT, BOTTOM_LEFT, diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabVariant.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabVariant.java new file mode 100644 index 0000000..d63f183 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabVariant.java @@ -0,0 +1,86 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.chatassistant.model; + +import com.vaadin.flow.component.button.ButtonVariant; + +/** + * Theme variants supported by the chat assistant floating action button (FAB). + * + *

Variants fall into two groups: the size variants {@link #SMALL} and {@link #LARGE} + * resize the FAB to a predefined diameter and are mutually exclusive (only one is active at a time), + * while the color variants are applied to the FAB and accumulate. All variants work under + * both the Lumo and Aura themes, except {@link #LUMO_CONTRAST}, which is only effective under Lumo. + * + * @since 5.1.0 + */ +public enum FabVariant { + + /** Renders the FAB at the small predefined diameter. */ + SMALL(ButtonVariant.LUMO_SMALL, true, null), + /** Renders the FAB at the large predefined diameter. */ + LARGE(ButtonVariant.LUMO_LARGE, true, null), + /** Applies the primary color to the FAB. */ + PRIMARY(ButtonVariant.LUMO_PRIMARY, false, null), + /** Applies the success (green) color to the FAB. */ + SUCCESS(ButtonVariant.LUMO_SUCCESS, false, "aura-accent-green"), + /** Applies the error (red) color to the FAB. */ + ERROR(ButtonVariant.LUMO_ERROR, false, "aura-accent-red"), + /** Applies the contrast color to the FAB. Effective only under the Lumo theme. */ + LUMO_CONTRAST(ButtonVariant.LUMO_CONTRAST, false, null); + + private final ButtonVariant buttonVariant; + private final boolean sizeVariant; + private final String auraClass; + + FabVariant(ButtonVariant buttonVariant, boolean sizeVariant, String auraClass) { + this.buttonVariant = buttonVariant; + this.sizeVariant = sizeVariant; + this.auraClass = auraClass; + } + + /** + * Returns whether this is a size variant ({@link #SMALL} or {@link #LARGE}), which resizes the FAB. + * + * @return {@code true} if this is a size variant + */ + public boolean isSizeVariant() { + return sizeVariant; + } + + /** + * Returns the Vaadin button variant that carries this variant's color. + * + * @return the underlying {@link ButtonVariant} + */ + public ButtonVariant getButtonVariant() { + return buttonVariant; + } + + /** + * Returns the Aura accent CSS class for this variant, or {@code null} if it renders from its Lumo + * token alone. Aura styles its accent colors via a CSS class rather than the theme attribute. + * + * @return the Aura accent class, or {@code null} + */ + public String getAuraClass() { + return auraClass; + } +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/Message.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/Message.java index 67e6408..75852ea 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/Message.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/Message.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -39,13 +39,10 @@ @EqualsAndHashCode(of = "id") public class Message implements Serializable { - @Builder.Default - private UUID id = UUID.randomUUID(); - @Builder.Default - private String content = ""; + @Builder.Default private UUID id = UUID.randomUUID(); + @Builder.Default private String content = ""; private boolean loading; private String name; private String avatar; private LocalDateTime messageTime; - } diff --git a/src/main/resources/META-INF/VAADIN/package.properties b/src/main/resources/META-INF/VAADIN/package.properties index c66616f..7225f8d 100644 --- a/src/main/resources/META-INF/VAADIN/package.properties +++ b/src/main/resources/META-INF/VAADIN/package.properties @@ -1 +1,20 @@ +### +# #%L +# Chat Assistant Add-on +# %% +# Copyright (C) 2023 - 2026 Flowing Code +# %% +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# #L% +### vaadin.allowed-packages=com.flowingcode diff --git a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js index f11eadf..4e9e598 100644 --- a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js +++ b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js @@ -7,9 +7,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -17,6 +17,25 @@ * limitations under the License. * #L% */ +// The root element () has no backing web component, so register a minimal custom +// element whose disconnectedCallback runs the teardown callbacks that the movement and resize +// modules push onto root.__fcCleanups. Without an upgraded element the browser never fires +// disconnectedCallback, leaving the window/observer listeners uncleaned and the init guards set. +if (!customElements.get('animated-fab')) { + customElements.define('animated-fab', class extends HTMLElement { + disconnectedCallback() { + (this.__fcCleanups || []).forEach(fn => { + try { + fn(); + } catch (e) { + // keep running the remaining teardown callbacks + } + }); + this.__fcCleanups = []; + } + }); +} + // Resolves the FAB's rendered size, falling back to the offset/CSS size when the element has not // been laid out yet (getBoundingClientRect returns 0 before the first layout pass). function fcChatAssistantSize(fab) { @@ -85,13 +104,14 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po }; window.addEventListener("resize", resizeHandler); - const origDisconnectedCallback = root.disconnectedCallback?.bind(root); - root.disconnectedCallback = () => { + (root.__fcCleanups = root.__fcCleanups || []).push(() => { window.removeEventListener("resize", resizeHandler); window.fcChatAssistantMobileModeOff?.(root); window.fcChatAssistantScreenSizeOffAll?.(root); - origDisconnectedCallback?.(); - }; + // Clear the init guards so movement re-initializes on reattach. + item[guard] = false; + root['fc-chat-assistant-drag-listener'] = null; + }); // Update FAB position function updatePosition() { @@ -135,13 +155,17 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po item.addEventListener('pointermove', (e) => { if (!isDragging) return; const itemRect = fab.getBoundingClientRect(); - // Calculate position from right and bottom edges, keeping the FAB centered on the cursor. - position.x = screenWidth - e.clientX - (itemRect.width / 2); - position.y = screenHeight - e.clientY - (itemRect.height / 2); - // Do not move if delta is below sensitivity - if (isClickOnlyEvent()) { + // Candidate position from the right/bottom edges, keeping the FAB centered on the cursor. + const nextX = screenWidth - e.clientX - (itemRect.width / 2); + const nextY = screenHeight - e.clientY - (itemRect.height / 2); + // Ignore movement below the sensitivity threshold, and do not mutate position for it, so a + // click never nudges the FAB when it is later committed by snapToBoundary/updatePosition. + if (Math.abs(nextX - initialPosition.x) < sensitivity + && Math.abs(nextY - initialPosition.y) < sensitivity) { return; } + position.x = nextX; + position.y = nextY; updatePosition(); }); @@ -302,7 +326,12 @@ window.fcChatAssistantScreenSizeOff = (root, key) => { window.fcChatAssistantScreenSizeOffAll = (root) => { const listeners = root.__fcScreenSizeListeners; if (listeners) { - Object.keys(listeners).forEach((key) => listeners[key].observer?.disconnect()); + Object.keys(listeners).forEach((key) => { + listeners[key].observer?.disconnect(); + // Clear the per-key refresh guard too (mirroring fcChatAssistantScreenSizeOff), otherwise it + // would block re-registration of the same key after a detach/reattach. + root['fc-chat-assistant-screen-size-' + key] = null; + }); } root.__fcScreenSizeListeners = {}; }; diff --git a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js index 7e083db..2c9f537 100644 --- a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js +++ b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js @@ -7,9 +7,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -22,7 +22,7 @@ // `container` is the chat overlay Div (it fills the popover content part, so its rendered size is the // current content size). Resizing writes the desired size onto the popover's public content-height/ // content-width, which Vaadin clamps to the viewport, so the content can never overflow the popover. -window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw, direction) => { +window.fcChatAssistantResize = (root, item, container, popoverTag, sizeRaw, maxSizeRaw, direction) => { // Prevent duplicate initialization. The handlers always attach once; whether a drag is allowed is // decided live (see isResizable) so toggling resizable after init takes effect immediately. const guard = `__fcChatAssistantResize_${direction}`; @@ -37,7 +37,6 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const size = parseFloat(sizeRaw); const maxSize = parseFloat(maxSizeRaw); - const overlayTag = "vaadin-popover-overlay".toUpperCase(); let minWidth = 0; let minHeight = 0; @@ -288,27 +287,45 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw } window.requestAnimationFrame(fetchOverlay); - setTimeout(fetchOverlay, 2000); // in case the overlay is not available immediately, check again after 2 seconds + // In case the overlay is not available immediately, check again after 2 seconds. Tracked so it can + // be cancelled on disconnect (it may otherwise fire against a torn-down component). + const fetchOverlayTimeout = setTimeout(fetchOverlay, 2000); - // Fetch the root overlay component and its content part. + // Fetch the root overlay component and its content part. The popover rebuilds its overlay (and the + // content part) on each open, so re-resolve whenever the cached nodes are gone or detached — keeping + // stale (disconnected) references out of shouldDrag()/setContentWidth()/setContentHeight(). function fetchOverlay() { - if (!overlay) { - overlay = document.querySelector(`.${popoverTag}`)?.shadowRoot?.querySelector(overlayTag); - if(!overlay) { - overlay = [...document.getElementsByClassName(popoverTag)].find(p => p.tagName == overlayTag); - } + if (!overlay || !overlay.isConnected) { + contentPart = null; // a new overlay means the old content part is stale too + overlay = resolveOverlay(popoverTag); if (overlay) { observeOverlayStyle(); } } - if (overlay && !contentPart) { + if (overlay && (!contentPart || !contentPart.isConnected)) { contentPart = overlay.shadowRoot?.querySelector('[part="content"]'); } } - window.addEventListener('resize', () => updateCanDrag()); + const resizeHandler = () => updateCanDrag(); + window.addEventListener('resize', resizeHandler); + + // Teardown on detach (run by the animated-fab custom element's disconnectedCallback, defined in + // fc-chat-assistant-movement.js): drop this direction's window resize listener, disconnect its + // style observer, cancel the pending overlay lookup, and clear the init guards so the direction + // re-initializes on reattach. Runs only on a genuine detach, not on popover reopen. + (root.__fcCleanups = root.__fcCleanups || []).push(() => { + window.removeEventListener('resize', resizeHandler); + styleObserver?.disconnect(); + clearTimeout(fetchOverlayTimeout); + item[guard] = false; + root['fc-chat-assistant-resize-' + direction + '-listener'] = null; + }); item.addEventListener('pointerenter', (e) => { + // Refresh the overlay/content-part references in case the popover was closed and reopened since + // the last interaction (which rebuilds the overlay's shadow DOM). + fetchOverlay(); updateCanDrag(); if (isResizable() && config.shouldDrag()) { item.classList.add('active'); @@ -365,12 +382,19 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw } }; +// Resolves the popover's overlay element across Vaadin 24 (the overlay carries the class directly) and +// Vaadin 25 (the overlay lives inside the popover's shadow root). Shared by fetchOverlay() and +// fcChatAssistantContentPart() so the selector chain is defined in one place. +function resolveOverlay(popoverTag) { + const overlayTag = "vaadin-popover-overlay".toUpperCase(); + return document.querySelector(`.${popoverTag}`)?.shadowRoot?.querySelector(overlayTag) + || [...document.getElementsByClassName(popoverTag)].find(p => p.tagName === overlayTag); +} + // Resolves the popover overlay's [part='content'] element, retrying briefly because the overlay is // (re)created lazily when the popover opens. function fcChatAssistantContentPart(popoverTag, callback, attempts = 0) { - const overlayTag = "vaadin-popover-overlay".toUpperCase(); - const overlay = document.querySelector(`.${popoverTag}`)?.shadowRoot?.querySelector(overlayTag) - || [...document.getElementsByClassName(popoverTag)].find(p => p.tagName === overlayTag); + const overlay = resolveOverlay(popoverTag); const contentPart = overlay?.shadowRoot?.querySelector('[part="content"]'); if (contentPart) { callback(contentPart); @@ -389,8 +413,14 @@ function fcChatAssistantContentPart(popoverTag, callback, attempts = 0) { // downward, and the overlay Div is a descendant of the content part). window.fcChatAssistantApplyConstraints = (overlayDiv, popoverTag) => { const raw = (prop) => overlayDiv.style.getPropertyValue(prop); + // Numeric bound in px, or the default for non-px units (%/vw/…), which are left to the CSS + // min/max the content part already carries rather than clamped numerically here. const num = (prop, dflt) => { - const value = parseFloat(raw(prop)); + const rawValue = raw(prop); + if (!/^\s*\d*\.?\d+(px)?\s*$/.test(rawValue)) { + return dflt; + } + const value = parseFloat(rawValue); return Number.isFinite(value) ? value : dflt; }; const widthRaw = raw('--fc-width'); @@ -400,8 +430,13 @@ window.fcChatAssistantApplyConstraints = (overlayDiv, popoverTag) => { const maxWidth = raw('--fc-max-width'); const maxHeight = raw('--fc-max-height'); - // Clamp a desired size string into [min, max] (min wins if they cross, matching CSS). + // Clamp a desired size string into [min, max] (min wins if they cross, matching CSS). Only plain + // px/number lengths are clamped numerically; other units (%/vw/vh/rem/…) pass through verbatim + // and are left to the CSS min/max-width already applied to the content part. const clamp = (valueRaw, minProp, maxProp) => { + if (!/^\s*\d*\.?\d+(px)?\s*$/.test(valueRaw)) { + return valueRaw; + } const value = parseFloat(valueRaw); if (!Number.isFinite(value)) { return valueRaw; @@ -421,13 +456,6 @@ window.fcChatAssistantApplyConstraints = (overlayDiv, popoverTag) => { }); }; -// Sets the chat window's initial/desired width or height. Stores it on the durable overlay Div (so it -// survives close/reopen) and (re)applies all constraints, clamping the new size to the current bounds. -window.fcChatAssistantSetWindowSize = (overlayDiv, popoverTag, dimension, value) => { - overlayDiv.style.setProperty(dimension === 'height' ? '--fc-height' : '--fc-width', value); - window.fcChatAssistantApplyConstraints(overlayDiv, popoverTag); -}; - // Re-applies the desired size and bounds (stored on the overlay Div) to the content part. Called whenever // the popover opens, because Vaadin rebuilds the overlay's shadow DOM and the inline size is lost. window.fcChatAssistantRestoreWindowSize = (overlayDiv, popoverTag) => { diff --git a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css index 6c22786..d6efa65 100644 --- a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css +++ b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css @@ -7,9 +7,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -22,19 +22,20 @@ .fc-chat-assistant-fab { cursor: pointer; transform: scale(1); - box-shadow: var(--lumo-box-shadow-m); + /* Cross-theme fallback: Lumo token, then a literal shadow (Aura has no equivalent scale token). */ + box-shadow: var(--lumo-box-shadow-m, 0 2px 6px rgba(0, 0, 0, 0.2)); } /* Expansion state when being dragged */ .fc-chat-assistant-fab.dragging { cursor: grabbing; transform: scale(1.15); - box-shadow: var(--lumo-box-shadow-xl); + box-shadow: var(--lumo-box-shadow-xl, 0 8px 24px rgba(0, 0, 0, 0.3)); } .fc-chat-assistant-popover { - --vaadin-popover-offset-top : 15px; - --vaadin-popover-offset-bottom : 15px; + --vaadin-popover-offset-top: 15px; + --vaadin-popover-offset-bottom: 15px; } .fc-chat-assistant-unread-badge { @@ -42,50 +43,33 @@ } vaadin-popover-overlay::part(overlay) { - border-radius: var(--lumo-border-radius-l); + /* Cross-theme fallback: Lumo radius token, then Aura base radius, then a literal. */ + border-radius: var(--lumo-border-radius-l, var(--aura-base-radius, 12px)); } /* The chat overlay fills this part; the part already clamps itself to the viewport (max-height/width: 100%), so the content shrinks with the popover instead of overflowing. The min/max bounds are applied as inline styles from JS (fcChatAssistantApplyConstraints), sourced from the component's configured values, so they are intentionally not hardcoded here. */ -vaadin-popover-overlay.fc-chat-assistant-popover::part(content) { - display: flex; - flex-direction: column; - max-width : 100%; - max-height: 100%; -} - +/* The Vaadin 24 (vaadin-popover-overlay) and Vaadin 25 (vaadin-popover) selectors share one rule. */ +vaadin-popover-overlay.fc-chat-assistant-popover::part(content), vaadin-popover.fc-chat-assistant-popover::part(content) { display: flex; flex-direction: column; - max-width : 100%; + max-width: 100%; max-height: 100%; } -/* Let the chat overlay Div shrink so its VirtualList scrolls internally under the clamp. */ -vaadin-popover-overlay.fc-chat-assistant-popover::part(content) > * { - min-height: 0; - min-width: 0; -} - -/* Let the chat overlay Div shrink so its VirtualList scrolls internally under the clamp. */ -vaadin-popover.fc-chat-assistant-popover::part(content) > * { - min-height: 0; - min-width: 0; -} - -vaadin-dialog.fc-chat-assistant-dialog::part(content) { - height : 100%; - width : 100%; - display : flex; - align-items: stretch; -} +/* The chat overlay Div itself carries min-width/min-height: 0 (set from Java) so it can shrink and let + its VirtualList scroll internally under the clamp; that cannot be expressed here because a combinator + after ::part(content) is invalid CSS (rejected by lightningcss). */ +/* Likewise, the Vaadin 25 (vaadin-dialog) and Vaadin 24 (vaadin-dialog-overlay) dialog selectors. */ +vaadin-dialog.fc-chat-assistant-dialog::part(content), vaadin-dialog-overlay.fc-chat-assistant-dialog::part(content) { - height : 100%; - width : 100%; - display : flex; + height: 100%; + width: 100%; + display: flex; align-items: stretch; } @@ -112,7 +96,7 @@ vaadin-dialog-overlay.fc-chat-assistant-dialog::part(content) { /* filled triangle pointing right by default; rotated per direction below */ border-top: 5px solid transparent; border-bottom: 5px solid transparent; - border-left: 7px solid var(--lumo-contrast-20pct); + border-left: 7px solid var(--lumo-contrast-20pct, var(--aura-border-color, rgba(0, 0, 0, 0.2))); } /* Feature enabled (overlay class) AND this resizer is draggable now (can-drag on the resizer). */ diff --git a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-message-styles.css b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-message-styles.css index 43f24ed..948419c 100644 --- a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-message-styles.css +++ b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-message-styles.css @@ -19,7 +19,7 @@ */ .lds-ellipsis { /* change color here */ - color: #1c4c5b + color: #1c4c5b; } .lds-ellipsis, .lds-ellipsis div { @@ -85,17 +85,17 @@ width: 100%; padding: var(--lumo-space-xs, var(--vaadin-padding-xs, 0.25rem)); font-size: var(--lumo-font-size-xs, var(--aura-font-size-s, small)); - color: var(--lumo-secondary-text-color, var(--vaadin-text-color-secondary)); + color: var(--lumo-secondary-text-color, var(--vaadin-text-color-secondary, #697177)); } /* Markdown messages render block HTML into the vaadin-markdown light DOM, whose default block margins add whitespace inside and between message bubbles. Collapse the outer margins and tighten the gaps. */ -vaadin-markdown > :first-child { +vaadin-markdown.fc-chat-message-markdown > :first-child { margin-top: 0; } -vaadin-markdown > :last-child { +vaadin-markdown.fc-chat-message-markdown > :last-child { margin-bottom: 0; } -vaadin-markdown > * + * { +vaadin-markdown.fc-chat-message-markdown > * + * { margin-top: var(--lumo-space-s, 0.5rem); } diff --git a/src/main/resources/META-INF/resources/icons/chatbot.svg b/src/main/resources/META-INF/resources/icons/chatbot.svg index 5acc55a..159f2ab 100644 --- a/src/main/resources/META-INF/resources/icons/chatbot.svg +++ b/src/main/resources/META-INF/resources/icons/chatbot.svg @@ -1,4 +1,24 @@ + + chatAssistant = ChatAssistant.builder() - .fabIcon(icon) - .fabAnchoredToViewport(false) - .build(); + ChatAssistant chatAssistant = + ChatAssistant.builder().fabIcon(icon).fabAnchoredToViewport(false).build(); chatAssistant.setWindowWidth("400px"); chatAssistant.setWindowHeight("400px"); @@ -60,20 +58,22 @@ public ChatAssistantBoxDemo() { .messageTime(LocalDateTime.now()) .name("Assistant") .avatar("chatbot.png") - .build() - ); + .build()); // Move the FAB to each corner of the box. - HorizontalLayout controls = new HorizontalLayout( - new Button("Top left", ev -> chatAssistant.setFabPosition(FabPosition.TOP_LEFT)), - new Button("Top right", ev -> chatAssistant.setFabPosition(FabPosition.TOP_RIGHT)), - new Button("Bottom left", ev -> chatAssistant.setFabPosition(FabPosition.BOTTOM_LEFT)), - new Button("Bottom right", ev -> chatAssistant.setFabPosition(FabPosition.BOTTOM_RIGHT)) - ); + HorizontalLayout controls = + new HorizontalLayout( + new Button("Top left", ev -> chatAssistant.setFabPosition(FabPosition.TOP_LEFT)), + new Button("Top right", ev -> chatAssistant.setFabPosition(FabPosition.TOP_RIGHT)), + new Button("Bottom left", ev -> chatAssistant.setFabPosition(FabPosition.BOTTOM_LEFT)), + new Button( + "Bottom right", ev -> chatAssistant.setFabPosition(FabPosition.BOTTOM_RIGHT))); controls.getStyle().set("flex-wrap", "wrap"); // A visible, relatively-positioned box that hosts the non-fixed FAB. - Span description = new Span("The FAB is not anchored to the viewport but positioned relative to this box. This behaviour disables dragging."); + Span description = + new Span( + "The FAB is not anchored to the viewport but positioned relative to this box. This behaviour disables dragging."); Div box = new Div(chatAssistant); box.getStyle() .set("position", "relative") diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java index 32056c0..394aa51 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java @@ -2,14 +2,14 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -22,6 +22,7 @@ import com.flowingcode.vaadin.addons.demo.DemoSource; import com.flowingcode.vaadin.addons.demo.SourcePosition; import com.google.common.base.Strings; +import com.vaadin.flow.component.DetachEvent; import com.vaadin.flow.component.UI; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.dependency.CssImport; @@ -32,8 +33,9 @@ import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; import java.time.LocalDateTime; -import java.util.Timer; -import java.util.TimerTask; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; @DemoSource(sourcePosition = SourcePosition.PRIMARY) @PageTitle("Basic Demo") @@ -41,6 +43,16 @@ @CssImport("./styles/chat-assistant-styles-demo.css") public class ChatAssistantDemo extends VerticalLayout { + // Daemon-threaded scheduler for the delayed "thinking" reply, so it never blocks JVM shutdown; it is + // shut down when the view detaches. + private final ScheduledExecutorService scheduler = + Executors.newSingleThreadScheduledExecutor( + r -> { + Thread t = new Thread(r, "chat-assistant-demo-scheduler"); + t.setDaemon(true); + return t; + }); + public ChatAssistantDemo() { // Create the assistant with all defaults ChatAssistant chatAssistant = new ChatAssistant<>(); @@ -49,82 +61,99 @@ public ChatAssistantDemo() { chatAssistant.setWindowHeight("400px"); // Render each message with a custom component that also shows its tagline. - chatAssistant.setMessagesRenderer(new ComponentRenderer( - CustomChatMessage::new, - (component, message) -> { - ((CustomChatMessage) component).setMessage(message); - return component; - })); + chatAssistant.setMessagesRenderer( + new ComponentRenderer( + CustomChatMessage::new, + (component, message) -> { + ((CustomChatMessage) component).setMessage(message); + return component; + })); // Echo messages submitted by the user from the chat input. - chatAssistant.setSubmitListener(se -> chatAssistant.sendMessage(CustomMessage.builder() - .messageTime(LocalDateTime.now()).name("User").content(se.getValue()) - .tagline("Generated by user").build())); + chatAssistant.setSubmitListener( + se -> + chatAssistant.sendMessage( + CustomMessage.builder() + .messageTime(LocalDateTime.now()) + .name("User") + .content(se.getValue()) + .tagline("Generated by user") + .build())); // Text area used to compose the assistant's answer. TextArea message = new TextArea(); message.setLabel("Enter a message from the assistant"); message.setSizeFull(); - message.addKeyPressListener(ev -> { - if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { - chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); - } - }); + message.addKeyPressListener( + ev -> { + if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { + chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); + } + }); message.addBlurListener(ev -> chatAssistant.clearWhoIsTyping()); // Send the composed text as an assistant message. Button chat = new Button("Chat"); - chat.addClickListener(ev -> { - chatAssistant.sendMessage(CustomMessage.builder().content(message.getValue()) - .messageTime(LocalDateTime.now()).name("Assistant").avatar("chatbot.png") - .tagline("Generated by assistant").build()); - message.clear(); - }); + chat.addClickListener( + ev -> { + chatAssistant.sendMessage( + CustomMessage.builder() + .content(message.getValue()) + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build()); + message.clear(); + }); // Send a message in a loading state, then resolve it after 5 seconds. Button chatWithThinking = new Button("Chat With Thinking"); - chatWithThinking.addClickListener(ev -> { - CustomMessage delayedMessage = CustomMessage.builder() - .loading(true) - .content(message.getValue()) - .messageTime(LocalDateTime.now()) - .name("Assistant") - .avatar("chatbot.png") - .tagline("Generated by assistant") - .build(); - chatAssistant.sendMessage(delayedMessage); - - UI currentUI = UI.getCurrent(); - new Timer().schedule(new TimerTask() { - @Override - public void run() { - currentUI.access(() -> { - delayedMessage.setLoading(false); - chatAssistant.updateMessage(delayedMessage); - }); - } - }, 5000); - message.clear(); - }); + chatWithThinking.addClickListener( + ev -> { + CustomMessage delayedMessage = + CustomMessage.builder() + .loading(true) + .content(message.getValue()) + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build(); + chatAssistant.sendMessage(delayedMessage); + + UI currentUI = UI.getCurrent(); + scheduler.schedule( + () -> + currentUI.access( + () -> { + delayedMessage.setLoading(false); + chatAssistant.updateMessage(delayedMessage); + }), + 5, + TimeUnit.SECONDS); + message.clear(); + }); Button unread = new Button("Toggle unread badge"); - unread.addClickListener(ev -> { - if (chatAssistant.getUnreadMessages() > 0) { - chatAssistant.setUnreadMessages(0); - } else { - chatAssistant.setUnreadMessages((int) (Math.random() * 98) + 1); - } - }); + unread.addClickListener( + ev -> { + if (chatAssistant.getUnreadMessages() > 0) { + chatAssistant.setUnreadMessages(0); + } else { + chatAssistant.setUnreadMessages((int) (Math.random() * 98) + 1); + } + }); // Seed the conversation chatAssistant.sendMessage( - CustomMessage.builder() - .content("Hello, I am here to assist you") - .messageTime(LocalDateTime.now()) - .name("Assistant") - .avatar("chatbot.png") - .tagline("Generated by assistant").build() - ); + CustomMessage.builder() + .content("Hello, I am here to assist you") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build()); HorizontalLayout row = new HorizontalLayout(chat, chatWithThinking, unread); row.setSpacing(true); @@ -136,4 +165,10 @@ public void run() { add(message, row, chatAssistant); } + + @Override + protected void onDetach(DetachEvent detachEvent) { + scheduler.shutdownNow(); + super.onDetach(detachEvent); + } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemoView.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemoView.java index 9e5dd8d..e0fbe74 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemoView.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java index cb1aaea..a849b09 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java @@ -2,14 +2,14 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2025 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -19,11 +19,11 @@ */ package com.flowingcode.vaadin.addons.chatassistant; +import com.flowingcode.vaadin.addons.chatassistant.model.FabVariant; import com.flowingcode.vaadin.addons.chatassistant.model.Message; import com.flowingcode.vaadin.addons.demo.DemoSource; import com.flowingcode.vaadin.addons.demo.SourcePosition; import com.vaadin.flow.component.button.Button; -import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.dependency.CssImport; import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.icon.SvgIcon; @@ -43,64 +43,86 @@ public class ChatAssistantFabConfigDemo extends VerticalLayout { public ChatAssistantFabConfigDemo() { SvgIcon icon = new SvgIcon("chatbot.svg"); - // Build the assistant with a custom FAB icon via the builder. - ChatAssistant chatAssistant = ChatAssistant.builder().fabIcon(icon).build(); - chatAssistant.setWindowWidth("400px"); - chatAssistant.setWindowHeight("400px"); + // Build the assistant with a custom FAB icon and initial window size via the builder. + ChatAssistant chatAssistant = + ChatAssistant.builder().fabIcon(icon).width("400px").height("400px").build(); - // Size variants resize the FAB (and its icon): SMALL (50px) and LARGE (72px); removing the active - // one restores the default (60px). Only one size is active at a time. - Button small = new Button("Small", - ev -> chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_SMALL) - ); - Button large = new Button("Large", - ev -> chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_LARGE) - ); - Button defaultSize = new Button("Default size", ev -> chatAssistant.removeFabThemeVariants(ButtonVariant.LUMO_SMALL, - ButtonVariant.LUMO_LARGE) - ); + // Size variants resize the FAB (and its icon): SMALL (50px) and LARGE (72px); removing the + // active one restores the default (60px). Only one size is active at a time. + Button small = new Button("Small", ev -> chatAssistant.addFabThemeVariants(FabVariant.SMALL)); + Button large = new Button("Large", ev -> chatAssistant.addFabThemeVariants(FabVariant.LARGE)); + Button defaultSize = + new Button( + "Default size", + ev -> chatAssistant.removeFabThemeVariants(FabVariant.SMALL, FabVariant.LARGE)); - // Color variants are applied to the underlying button; the icon follows the button color. - Button success = new Button("Success", - ev -> chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_SUCCESS) - ); - Button error = new Button("Error", - ev -> chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_ERROR) - ); - Button contrast = new Button("Contrast", - ev -> chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_CONTRAST) - ); - Button clearColors = new Button("Clear colors", ev -> chatAssistant.removeFabThemeVariants( - ButtonVariant.LUMO_SUCCESS, ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_CONTRAST) - ); + // Color variants accumulate, so replace the other colors before applying the chosen one; this + // keeps the FAB showing exactly the selected color. + Button success = + new Button( + "Success", + ev -> { + chatAssistant.removeFabThemeVariants(FabVariant.ERROR, FabVariant.LUMO_CONTRAST); + chatAssistant.addFabThemeVariants(FabVariant.SUCCESS); + }); + Button error = + new Button( + "Error", + ev -> { + chatAssistant.removeFabThemeVariants(FabVariant.SUCCESS, FabVariant.LUMO_CONTRAST); + chatAssistant.addFabThemeVariants(FabVariant.ERROR); + }); + Button contrast = + new Button( + "Contrast", + ev -> { + chatAssistant.removeFabThemeVariants(FabVariant.SUCCESS, FabVariant.ERROR); + chatAssistant.addFabThemeVariants(FabVariant.LUMO_CONTRAST); + }); + Button clearColors = + new Button( + "Clear colors", + ev -> + chatAssistant.removeFabThemeVariants( + FabVariant.SUCCESS, FabVariant.ERROR, FabVariant.LUMO_CONTRAST)); - // Lock down or free up the FAB and the window, and toggle the resize direction hints. Each toggle - // shows a notification reporting the resulting state. - Button movable = new Button("Toggle movable", ev -> { - chatAssistant.setFabMovable(!chatAssistant.isFabMovable()); - Notification.show("FAB movable: " + chatAssistant.isFabMovable()); - }); - Button resizable = new Button("Toggle resizable", ev -> { - chatAssistant.setWindowResizable(!chatAssistant.isWindowResizable()); - Notification.show("Window resizable: " + chatAssistant.isWindowResizable()); - }); - Button indicators = new Button("Toggle resize hints", ev -> { - chatAssistant.setResizeIndicatorsVisible(!chatAssistant.isResizeIndicatorsVisible()); - Notification.show("Resize hints visible: " + chatAssistant.isResizeIndicatorsVisible()); - }); + // Lock down or free up the FAB and the window, and toggle the resize direction hints. Each + // toggle shows a notification reporting the resulting state. + Button movable = + new Button( + "Toggle movable", + ev -> { + chatAssistant.setFabMovable(!chatAssistant.isFabMovable()); + Notification.show("FAB movable: " + chatAssistant.isFabMovable()); + }); + Button resizable = + new Button( + "Toggle resizable", + ev -> { + chatAssistant.setWindowResizable(!chatAssistant.isWindowResizable()); + Notification.show("Window resizable: " + chatAssistant.isWindowResizable()); + }); + Button indicators = + new Button( + "Toggle resize hints", + ev -> { + chatAssistant.setResizeIndicatorsVisible(!chatAssistant.isResizeIndicatorsVisible()); + Notification.show( + "Resize hints visible: " + chatAssistant.isResizeIndicatorsVisible()); + }); - // Seed the conversation and open the window so the styling and resize hints are visible right away. + // Seed the conversation and open the window so the styling and resize hints are visible. chatAssistant.sendMessage( Message.builder() .content("Use the buttons to restyle the FAB.") .messageTime(LocalDateTime.now()) .name("Assistant") .avatar("chatbot.png") - .build() - ); + .build()); chatAssistant.setOpened(true); - add(section("Size variants", small, large, defaultSize), + add( + section("Size variants", small, large, defaultSize), section("Color variants", success, error, contrast, clearColors), section("Behavior", movable, resizable, indicators), chatAssistant); @@ -108,14 +130,14 @@ public ChatAssistantFabConfigDemo() { /** A titled group of buttons whose row wraps on narrow screens. */ private static VerticalLayout section(String title, Button... buttons) { - Span heading = new Span(title); + Span heading = new Span(title); - HorizontalLayout row = new HorizontalLayout(buttons); - row.getStyle().set("flex-wrap", "wrap"); + HorizontalLayout row = new HorizontalLayout(buttons); + row.getStyle().set("flex-wrap", "wrap"); - VerticalLayout group = new VerticalLayout(heading, row); - group.setPadding(false); - group.setSpacing(false); - return group; + VerticalLayout group = new VerticalLayout(heading, row); + group.setPadding(false); + group.setSpacing(false); + return group; } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java index b1cb3ae..eade5fe 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java @@ -2,14 +2,14 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2025 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -22,6 +22,7 @@ import com.flowingcode.vaadin.addons.demo.DemoSource; import com.flowingcode.vaadin.addons.demo.SourcePosition; import com.google.common.base.Strings; +import com.vaadin.flow.component.DetachEvent; import com.vaadin.flow.component.UI; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.dependency.CssImport; @@ -33,11 +34,11 @@ import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; import java.time.LocalDateTime; -import java.util.Arrays; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; -import java.util.stream.Stream; @DemoSource(sourcePosition = SourcePosition.PRIMARY) @PageTitle("Generative Answer Demo") @@ -45,11 +46,22 @@ @CssImport("./styles/chat-assistant-styles-demo.css") public class ChatAssistantGenerativeDemo extends VerticalLayout { + // Dedicated daemon executor for the streaming task, so its blocking sleeps never tie up the shared + // ForkJoinPool.commonPool(); shut down when the view detaches. + private final ExecutorService executor = + Executors.newSingleThreadExecutor( + r -> { + Thread t = new Thread(r, "chat-assistant-generative-demo"); + t.setDaemon(true); + return t; + }); + public ChatAssistantGenerativeDemo() { - String sampleText = "Hi, I'm an advanced language model. I'm here to help you demonstrate" - + " how a text-streaming chat component works in Vaadin. As you can see, each word appears" - + " with a slight pause, simulating the time it would take me to \"think\" and generate" - + " the next word. I hope this is useful for your demonstration!"; + String sampleText = + "Hi, I'm an advanced language model. I'm here to help you demonstrate" + + " how a text-streaming chat component works in Vaadin. As you can see, each word appears" + + " with a slight pause, simulating the time it would take me to \"think\" and generate" + + " the next word. I hope this is useful for your demonstration!"; // Create the assistant with all defaults and give it a custom FAB icon. ChatAssistant chatAssistant = new ChatAssistant<>(); @@ -60,91 +72,104 @@ public ChatAssistantGenerativeDemo() { chatAssistant.setWindowHeight("400px"); // Render each message with a custom component that also shows its tagline. - chatAssistant.setMessagesRenderer(new ComponentRenderer( - CustomChatMessage::new, - (component, message) -> { - ((CustomChatMessage) component).setMessage(message); - return component; - }) - ); + chatAssistant.setMessagesRenderer( + new ComponentRenderer( + CustomChatMessage::new, + (component, message) -> { + ((CustomChatMessage) component).setMessage(message); + return component; + })); // Echo messages submitted by the user from the chat input. - chatAssistant.setSubmitListener(se -> chatAssistant.sendMessage( - CustomMessage.builder() - .messageTime(LocalDateTime.now()) - .name("User") - .content(se.getValue()) - .tagline("Generated by user") - .build() - )); + chatAssistant.setSubmitListener( + se -> + chatAssistant.sendMessage( + CustomMessage.builder() + .messageTime(LocalDateTime.now()) + .name("User") + .content(se.getValue()) + .tagline("Generated by user") + .build())); // Text area pre-filled with the answer that will be streamed. TextArea message = new TextArea(); message.setLabel("Enter a message from the assistant"); message.setSizeFull(); message.setValue(sampleText); - message.addKeyPressListener(ev -> { - if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { - chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); - } - }); + message.addKeyPressListener( + ev -> { + if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { + chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); + } + }); message.addBlurListener(ev -> chatAssistant.clearWhoIsTyping()); // Send the composed text as a single assistant message. Button chat = new Button("Chat"); - chat.addClickListener(ev -> { - chatAssistant.sendMessage( - CustomMessage.builder() - .content(message.getValue()) - .messageTime(LocalDateTime.now()) - .name("Assistant") - .avatar("chatbot.png") - .tagline("Generated by assistant") - .build() - ); - message.clear(); - }); + chat.addClickListener( + ev -> { + chatAssistant.sendMessage( + CustomMessage.builder() + .content(message.getValue()) + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build()); + message.clear(); + }); // Send an empty loading message and stream the answer into it word by word. Button chatWithThinking = new Button("Chat With Generative Thinking"); - chatWithThinking.addClickListener(ev -> { - String answer = message.getValue(); - CustomMessage delayedMessage = CustomMessage.builder() - .loading(true) - .content("") - .messageTime(LocalDateTime.now()) - .name("Assistant") - .avatar("chatbot.png") - .tagline("Generated by assistant") - .build(); - chatAssistant.sendMessage(delayedMessage); + chatWithThinking.addClickListener( + ev -> { + String answer = message.getValue(); + CustomMessage delayedMessage = + CustomMessage.builder() + .loading(true) + .content("") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build(); + chatAssistant.sendMessage(delayedMessage); - UI currentUI = UI.getCurrent(); - CompletableFuture.runAsync(() -> { - try { - TimeUnit.MILLISECONDS.sleep(500); - currentUI.access(() -> delayedMessage.setLoading(false)); - streamWords(answer).forEach(word -> currentUI.access(() -> { - delayedMessage.setContent(delayedMessage.getContent() + word); - chatAssistant.updateMessage(delayedMessage); - })); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }); - message.clear(); - }); + UI currentUI = UI.getCurrent(); + CompletableFuture.runAsync( + () -> { + try { + TimeUnit.MILLISECONDS.sleep(500); + currentUI.access(() -> delayedMessage.setLoading(false)); + // Emit the answer word by word. Thread.sleep propagates InterruptedException on + // executor.shutdownNow(), aborting the loop so no further ui.access runs on a + // detached view. + for (String word : answer.split("\\s+")) { + Thread.sleep(ThreadLocalRandom.current().nextLong(50, 250)); + String chunk = word + " "; + currentUI.access( + () -> { + delayedMessage.setContent(delayedMessage.getContent() + chunk); + chatAssistant.updateMessage(delayedMessage); + }); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }, + executor); + message.clear(); + }); // Seed the conversation with a greeting and open the window. chatAssistant.sendMessage( - CustomMessage.builder() - .content("Hello, I am here to assist you") - .messageTime(LocalDateTime.now()) - .name("Assistant") - .avatar("chatbot.png") - .tagline("Generated by assistant") - .build() - ); + CustomMessage.builder() + .content("Hello, I am here to assist you") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build()); chatAssistant.setOpened(true); HorizontalLayout row = new HorizontalLayout(chat, chatWithThinking); @@ -153,18 +178,9 @@ public ChatAssistantGenerativeDemo() { add(message, row, chatAssistant); } - /** Splits the text into words and emits them one at a time with a small random delay. */ - private Stream streamWords(String fullText) { - if (fullText == null || fullText.isEmpty()) { - return Stream.empty(); - } - return Arrays.stream(fullText.split("\\s+")).map(word -> { - try { - Thread.sleep(ThreadLocalRandom.current().nextLong(50, 250)); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - return word + " "; - }); + @Override + protected void onDetach(DetachEvent detachEvent) { + executor.shutdownNow(); + super.onDetach(detachEvent); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java index de0438e..1864773 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java @@ -2,14 +2,14 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -61,12 +61,17 @@ public ChatAssistantLazyLoadingDemo() { // Feed messages through a lazy DataProvider, reporting the requested page as it loads. Span lazyLoadingData = new Span(); - DataProvider dataProvider = DataProvider.fromCallbacks(query -> { - lazyLoadingData.setText( - "Loading messages from: " + query.getOffset() + ", with limit: " + query.getLimit() - ); - return messages.stream().skip(query.getOffset()).limit(query.getLimit()); - }, query -> messages.size()); + DataProvider dataProvider = + DataProvider.fromCallbacks( + query -> { + lazyLoadingData.setText( + "Loading messages from: " + + query.getOffset() + + ", with limit: " + + query.getLimit()); + return messages.stream().skip(query.getOffset()).limit(query.getLimit()); + }, + query -> messages.size()); chatAssistant.setDataProvider(dataProvider); // Replace the default header with a custom title bar and minimize button. @@ -82,38 +87,42 @@ public ChatAssistantLazyLoadingDemo() { TextArea message = new TextArea(); message.setLabel("Enter a message from the assistant"); message.setSizeFull(); - message.addKeyPressListener(ev -> { - if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { - chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); - } - }); + message.addKeyPressListener( + ev -> { + if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { + chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); + } + }); message.addBlurListener(ev -> chatAssistant.clearWhoIsTyping()); // With a custom DataProvider, append to the list and refresh instead of using sendMessage. Button chat = new Button("Chat"); - chat.addClickListener(ev -> { - messages.add(Message.builder() - .content(message.getValue()) - .messageTime(LocalDateTime.now()) - .name("Assistant") - .avatar("chatbot.png") - .build()); - dataProvider.refreshAll(); - chatAssistant.scrollToEnd(); - message.clear(); - }); + chat.addClickListener( + ev -> { + messages.add( + Message.builder() + .content(message.getValue()) + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build()); + dataProvider.refreshAll(); + chatAssistant.scrollToEnd(); + message.clear(); + }); // Messages typed in the chat input are appended the same way. - chatAssistant.setSubmitListener(ev -> { - messages.add(Message.builder() - .messageTime(LocalDateTime.now()) - .name("User") - .content(ev.getValue()) - .build() - ); - dataProvider.refreshAll(); - chatAssistant.scrollToEnd(); - }); + chatAssistant.setSubmitListener( + ev -> { + messages.add( + Message.builder() + .messageTime(LocalDateTime.now()) + .name("User") + .content(ev.getValue()) + .build()); + dataProvider.refreshAll(); + chatAssistant.scrollToEnd(); + }); chatAssistant.setOpened(true); @@ -122,79 +131,131 @@ public ChatAssistantLazyLoadingDemo() { /** Sample conversation (an excerpt from Hamlet) used to populate the lazy data provider. */ private static List createMessages() { - return new ArrayList<>(Arrays.asList( - Message.builder().name("Claudius").content("I have sent to seek him and to find the body.\n" - + "How dangerous is it that this man goes loose!\n" - + "Yet must not we put the strong law on him.\n" - + "He's loved of the distracted multitude,\n" - + "Who like not in their judgment, but their eyes.\n" - + "And where 'tis so, th' offender's scourge is weighed,\n" - + "But never the offense. To bear all smooth and even,\n" - + "This sudden sending him away must seem\n" - + "Deliberate pause. Diseases desperate grown\n" - + "By desperate appliance are relieved,\n" - + "Or not at all.").build(), - Message.builder().name("Rosencrantz").content("(Enter)").build(), - Message.builder().name("Claudius").content("How now, what hath befall'n?").build(), - Message.builder().name("Rosencrantz").content("Where the dead body is bestowed, my lord,\n" - + "We cannot get from him.").build(), - Message.builder().name("Claudius").content("But where is he?").build(), - Message.builder().name("Rosencrantz").content("Without, my lord; guarded, to know your pleasure.").build(), - Message.builder().name("Claudius").content("Bring him before us.").build(), - Message.builder().name("Rosencrantz").content("Ho, Guildenstern! Bring in my lord.").build(), - Message.builder().name("Claudius").content("Now, Hamlet, where's Polonius?").build(), - Message.builder().name("Hamlet").content("At supper.").build(), - Message.builder().name("Claudius").content("At supper? Where? ").build(), - Message.builder().name("Hamlet").content("Not where he eats, but where he is eaten. A certain \n" - + "convocation of politic worms are e'en at him. Your worm is your \n" - + "only emperor for diet. We fat all creatures else to fat us, and \n" - + "we fat ourselves for maggots. Your fat king and your lean beggar \n" - + "is but variable service- two dishes, but to one table. That's the \n" - + "end.").build(), - Message.builder().name("Claudius").content("Alas, alas!").build(), - Message.builder().name("Hamlet").content("A man may fish with the worm that hath eat of a king, and eat \n" - + "of the fish that hath fed of that worm.").build(), - Message.builder().name("Claudius").content("What dost thou mean by this?").build(), - Message.builder().name("Hamlet").content("Nothing but to show you how a king may go a progress through \n" - + "the guts of a beggar.\n" - + "").build(), - Message.builder().name("Claudius").content("Where is Polonius?").build(), - Message.builder().name("Hamlet").content("In heaven. Send thither to see. If your messenger find him not \n" - + "there, seek him i' th' other place yourself. But indeed, if you\n" - + "find him not within this month, you shall nose him as you go up \n" - + "the stair, into the lobby.").build(), - Message.builder().name("Claudius").content("Go seek him there.").build(), - Message.builder().name("Hamlet").content("He will stay till you come.").build(), - Message.builder().name("Claudius").content("Hamlet, this deed, for thine especial safety,- \n" - + "Which we do tender as we dearly grieve \n" - + "For that which thou hast done,- must send thee hence \n" - + "With fiery quickness. Therefore prepare thyself. \n" - + "The bark is ready and the wind at help, \n" - + "Th' associates tend, and everything is bent \n" - + "For England.").build(), - Message.builder().name("Hamlet").content("For England?").build(), - Message.builder().name("Claudius").content("Ay, Hamlet.").build(), - Message.builder().name("Hamlet").content("Good.").build(), - Message.builder().name("Claudius").content("So is it, if thou knew'st our purposes.").build(), - Message.builder().name("Hamlet").content("I see a cherub that sees them. But come, for England! \n" - + "Farewell, dear mother.").build(), - Message.builder().name("Claudius").content("Thy loving father, Hamlet.").build(), - Message.builder().name("Hamlet").content("My mother! Father and mother is man and wife; man and wife is\n" - + "one flesh; and so, my mother. Come, for England!").build(), - Message.builder().name("Claudius").content("Follow him at foot; tempt him with speed aboard. \n" - + "Delay it not; I'll have him hence to-night. \n" - + "Away! for everything is seal'd and done\n" - + "That else leans on th' affair. Pray you make haste.").build(), - Message.builder().name("Claudius").content("And, England, if my love thou hold'st at aught,- \n" - + "As my great power thereof may give thee sense, \n" - + "Since yet thy cicatrice looks raw and red\n" - + "After the Danish sword, and thy free awe \n" - + "Pays homage to us,- thou mayst not coldly set \n" - + "Our sovereign process, which imports at full, \n" - + "By letters congruing to that effect, \n" - + "The present death of Hamlet. Do it, England; \n" - + "For like the hectic in my blood he rages, \n" - + "And thou must cure me. Till I know 'tis done, \n" - + "Howe'er my haps, my joys were ne'er begun. ").build())); + return new ArrayList<>( + Arrays.asList( + Message.builder() + .name("Claudius") + .content( + "I have sent to seek him and to find the body.\n" + + "How dangerous is it that this man goes loose!\n" + + "Yet must not we put the strong law on him.\n" + + "He's loved of the distracted multitude,\n" + + "Who like not in their judgment, but their eyes.\n" + + "And where 'tis so, th' offender's scourge is weighed,\n" + + "But never the offense. To bear all smooth and even,\n" + + "This sudden sending him away must seem\n" + + "Deliberate pause. Diseases desperate grown\n" + + "By desperate appliance are relieved,\n" + + "Or not at all.") + .build(), + Message.builder().name("Rosencrantz").content("(Enter)").build(), + Message.builder().name("Claudius").content("How now, what hath befall'n?").build(), + Message.builder() + .name("Rosencrantz") + .content("Where the dead body is bestowed, my lord,\n" + "We cannot get from him.") + .build(), + Message.builder().name("Claudius").content("But where is he?").build(), + Message.builder() + .name("Rosencrantz") + .content("Without, my lord; guarded, to know your pleasure.") + .build(), + Message.builder().name("Claudius").content("Bring him before us.").build(), + Message.builder() + .name("Rosencrantz") + .content("Ho, Guildenstern! Bring in my lord.") + .build(), + Message.builder().name("Claudius").content("Now, Hamlet, where's Polonius?").build(), + Message.builder().name("Hamlet").content("At supper.").build(), + Message.builder().name("Claudius").content("At supper? Where? ").build(), + Message.builder() + .name("Hamlet") + .content( + "Not where he eats, but where he is eaten. A certain \n" + + "convocation of politic worms are e'en at him. Your worm is your \n" + + "only emperor for diet. We fat all creatures else to fat us, and \n" + + "we fat ourselves for maggots. Your fat king and your lean beggar \n" + + "is but variable service- two dishes, but to one table. That's the \n" + + "end.") + .build(), + Message.builder().name("Claudius").content("Alas, alas!").build(), + Message.builder() + .name("Hamlet") + .content( + "A man may fish with the worm that hath eat of a king, and eat \n" + + "of the fish that hath fed of that worm.") + .build(), + Message.builder().name("Claudius").content("What dost thou mean by this?").build(), + Message.builder() + .name("Hamlet") + .content( + "Nothing but to show you how a king may go a progress through \n" + + "the guts of a beggar.\n" + + "") + .build(), + Message.builder().name("Claudius").content("Where is Polonius?").build(), + Message.builder() + .name("Hamlet") + .content( + "In heaven. Send thither to see. If your messenger find him not \n" + + "there, seek him i' th' other place yourself. But indeed, if you\n" + + "find him not within this month, you shall nose him as you go up \n" + + "the stair, into the lobby.") + .build(), + Message.builder().name("Claudius").content("Go seek him there.").build(), + Message.builder().name("Hamlet").content("He will stay till you come.").build(), + Message.builder() + .name("Claudius") + .content( + "Hamlet, this deed, for thine especial safety,- \n" + + "Which we do tender as we dearly grieve \n" + + "For that which thou hast done,- must send thee hence \n" + + "With fiery quickness. Therefore prepare thyself. \n" + + "The bark is ready and the wind at help, \n" + + "Th' associates tend, and everything is bent \n" + + "For England.") + .build(), + Message.builder().name("Hamlet").content("For England?").build(), + Message.builder().name("Claudius").content("Ay, Hamlet.").build(), + Message.builder().name("Hamlet").content("Good.").build(), + Message.builder() + .name("Claudius") + .content("So is it, if thou knew'st our purposes.") + .build(), + Message.builder() + .name("Hamlet") + .content( + "I see a cherub that sees them. But come, for England! \n" + + "Farewell, dear mother.") + .build(), + Message.builder().name("Claudius").content("Thy loving father, Hamlet.").build(), + Message.builder() + .name("Hamlet") + .content( + "My mother! Father and mother is man and wife; man and wife is\n" + + "one flesh; and so, my mother. Come, for England!") + .build(), + Message.builder() + .name("Claudius") + .content( + "Follow him at foot; tempt him with speed aboard. \n" + + "Delay it not; I'll have him hence to-night. \n" + + "Away! for everything is seal'd and done\n" + + "That else leans on th' affair. Pray you make haste.") + .build(), + Message.builder() + .name("Claudius") + .content( + "And, England, if my love thou hold'st at aught,- \n" + + "As my great power thereof may give thee sense, \n" + + "Since yet thy cicatrice looks raw and red\n" + + "After the Danish sword, and thy free awe \n" + + "Pays homage to us,- thou mayst not coldly set \n" + + "Our sovereign process, which imports at full, \n" + + "By letters congruing to that effect, \n" + + "The present death of Hamlet. Do it, England; \n" + + "For like the hectic in my blood he rages, \n" + + "And thou must cure me. Till I know 'tis done, \n" + + "Howe'er my haps, my joys were ne'er begun. ") + .build())); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java new file mode 100644 index 0000000..e8023dc --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java @@ -0,0 +1,177 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.chatassistant; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.flowingcode.vaadin.addons.chatassistant.model.FabVariant; +import com.flowingcode.vaadin.addons.chatassistant.model.Message; +import com.vaadin.flow.component.button.ButtonVariant; +import org.junit.Test; + +/** + * Unit tests for the pure-Java logic in {@link ChatAssistant} that does not require an attached UI: + * FAB margin parsing, unread-message clamping, screen-size listener argument validation, and the + * {@link FabVariant} mapping. These are exercised on an unattached component (no UI/DOM), mirroring + * the lightweight style of {@code SerializationTest}. + */ +public class ChatAssistantLogicTest { + + private ChatAssistant newChatAssistant() { + return new ChatAssistant<>(); + } + + // parseFabMargin ----------------------------------------------------------- + + @Test + public void parseFabMargin_null_returnsDefault() { + assertEquals(ChatAssistant.DEFAULT_FAB_MARGIN, newChatAssistant().parseFabMargin(null)); + } + + @Test + public void parseFabMargin_plainNumber_isParsed() { + assertEquals(30, newChatAssistant().parseFabMargin("30")); + } + + @Test + public void parseFabMargin_withPxSuffixAndWhitespace_isParsed() { + assertEquals(42, newChatAssistant().parseFabMargin(" 42px ")); + } + + @Test + public void parseFabMargin_invalid_returnsDefault() { + assertEquals( + ChatAssistant.DEFAULT_FAB_MARGIN, newChatAssistant().parseFabMargin("not-a-number")); + } + + @Test + public void parseFabMargin_negative_returnsDefault() { + assertEquals(ChatAssistant.DEFAULT_FAB_MARGIN, newChatAssistant().parseFabMargin("-5")); + } + + // setUnreadMessages clamping ---------------------------------------------- + + @Test + public void setUnreadMessages_negative_clampsToZero() { + ChatAssistant ca = newChatAssistant(); + ca.setUnreadMessages(-5); + assertEquals(0, ca.getUnreadMessages()); + } + + @Test + public void setUnreadMessages_aboveMax_clampsTo99() { + ChatAssistant ca = newChatAssistant(); + ca.setUnreadMessages(150); + assertEquals(99, ca.getUnreadMessages()); + } + + @Test + public void setUnreadMessages_inRange_isUnchanged() { + ChatAssistant ca = newChatAssistant(); + ca.setUnreadMessages(7); + assertEquals(7, ca.getUnreadMessages()); + } + + @Test + public void setUnreadMessages_boundaries_areKept() { + ChatAssistant ca = newChatAssistant(); + ca.setUnreadMessages(0); + assertEquals(0, ca.getUnreadMessages()); + ca.setUnreadMessages(99); + assertEquals(99, ca.getUnreadMessages()); + } + + // addScreenSizeListener validation ---------------------------------------- + + @Test + public void addScreenSizeListener_nullListener_throws() { + ChatAssistant ca = newChatAssistant(); + assertThrows(NullPointerException.class, () -> ca.addScreenSizeListener(100, 100, null)); + } + + @Test + public void addScreenSizeListener_bothThresholdsNull_throws() { + ChatAssistant ca = newChatAssistant(); + assertThrows( + IllegalArgumentException.class, () -> ca.addScreenSizeListener(null, null, ev -> {})); + } + + @Test + public void addScreenSizeListener_nonPositiveThreshold_throws() { + ChatAssistant ca = newChatAssistant(); + assertThrows(IllegalArgumentException.class, () -> ca.addScreenSizeListener(0, null, ev -> {})); + } + + @Test + public void addScreenSizeListener_validThreshold_returnsRegistration() { + ChatAssistant ca = newChatAssistant(); + assertNotNull(ca.addScreenSizeListener(200, null, ev -> {})); + } + + // FabVariant ------------------------------------------------------------- + + @Test + public void fabVariant_sizeClassification_isCorrect() { + assertTrue(FabVariant.SMALL.isSizeVariant()); + assertTrue(FabVariant.LARGE.isSizeVariant()); + assertFalse(FabVariant.SUCCESS.isSizeVariant()); + assertFalse(FabVariant.ERROR.isSizeVariant()); + assertFalse(FabVariant.LUMO_CONTRAST.isSizeVariant()); + assertFalse(FabVariant.PRIMARY.isSizeVariant()); + } + + @Test + public void fabVariant_colorMapping_hasThemeVariantAndAuraClass() { + // SUCCESS maps to a Lumo variant plus an Aura accent class (Aura styles accents via CSS class, + // not the theme attribute), so the color renders under both themes. + assertEquals(ButtonVariant.LUMO_SUCCESS, FabVariant.SUCCESS.getButtonVariant()); + assertEquals("aura-accent-green", FabVariant.SUCCESS.getAuraClass()); + } + + @Test + public void fabVariant_colorMapping_withoutAuraClass() { + // PRIMARY is cross-theme via its token, so it needs no extra Aura class. + assertEquals(ButtonVariant.LUMO_PRIMARY, FabVariant.PRIMARY.getButtonVariant()); + assertNull(FabVariant.PRIMARY.getAuraClass()); + } + + // isFabMovable effective state -------------------------------------------- + + @Test + public void isFabMovable_reflectsEffectiveDragState() { + ChatAssistant ca = newChatAssistant(); + // Defaults: movable and anchored to the viewport, so the FAB is draggable. + assertTrue(ca.isFabMovable()); + // A container-anchored FAB cannot be dragged, even though the movable preference is unchanged. + ca.setFabAnchoredToViewport(false); + assertFalse(ca.isFabMovable()); + // Re-anchoring to the viewport restores draggability. + ca.setFabAnchoredToViewport(true); + assertTrue(ca.isFabMovable()); + // Turning off movable makes it non-draggable regardless of anchoring. + ca.setFabMovable(false); + assertFalse(ca.isFabMovable()); + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java index 0411b8c..85ea78c 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java @@ -2,14 +2,14 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -51,40 +51,41 @@ public ChatAssistantMarkdownDemo() { TextArea message = new TextArea(); message.setLabel("Enter a message from the assistant (try using Markdown)"); message.setSizeFull(); - message.setValue("# Heading\n\n" - + "Some **bold** and *italic* text, with `inline code`.\n\n" - + "- First item\n" - + "- Second item\n"); - message.addKeyPressListener(ev -> { - if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { - chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); - } - }); + message.setValue( + "# Heading\n\n" + + "Some **bold** and *italic* text, with `inline code`.\n\n" + + "- First item\n" + + "- Second item\n"); + message.addKeyPressListener( + ev -> { + if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { + chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); + } + }); message.addBlurListener(ev -> chatAssistant.clearWhoIsTyping()); // Send the composed Markdown as an assistant message. Button chat = new Button("Chat"); - chat.addClickListener(ev -> { - chatAssistant.sendMessage( - Message.builder() - .content(message.getValue()) - .messageTime(LocalDateTime.now()) - .name("Assistant") - .avatar("chatbot.png") - .build() - ); - message.clear(); - }); + chat.addClickListener( + ev -> { + chatAssistant.sendMessage( + Message.builder() + .content(message.getValue()) + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build()); + message.clear(); + }); // Seed the conversation with a Markdown-formatted greeting and open the window. chatAssistant.sendMessage( - Message.builder() - .content("**Hello, I am here to assist you**") - .messageTime(LocalDateTime.now()) - .name("Assistant") - .avatar("chatbot.png") - .build() - ); + Message.builder() + .content("**Hello, I am here to assist you**") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build()); chatAssistant.setOpened(true); add(message, chat, chatAssistant); diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java index da79bca..d0b924b 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java @@ -2,14 +2,14 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2025 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -45,33 +45,36 @@ public ChatAssistantModeDemo() { // Build the assistant with auto-switching enabled: setting a breakpoint makes it switch to // mobile (full-screen dialog) below 768px and back to desktop (anchored popover) above it. - ChatAssistant chatAssistant = ChatAssistant.builder() - .fabIcon(icon) - .mobileBreakpoint(768) - .build(); + ChatAssistant chatAssistant = + ChatAssistant.builder().fabIcon(icon).mobileBreakpoint(768).build(); chatAssistant.setWindowWidth("400px"); chatAssistant.setWindowHeight("400px"); // React to every mode change, whether automatic or manual. chatAssistant.addModeChangedListener( - ev -> Notification.show("Switched to " + ev.getMode() + " mode") - ); + ev -> Notification.show("Switched to " + ev.getMode() + " mode")); // React to the chat window's own size crossing a 500px width threshold (independent of the // viewport breakpoint above). Fires once on registration and then on every crossing. - chatAssistant.addScreenSizeListener(500, null, - ev -> Notification.show("Chat window is now " + ev.getDirection() + " 500px wide") - ); + chatAssistant.addScreenSizeListener( + 500, + null, + ev -> Notification.show("Chat window is now " + ev.getDirection() + " 500px wide")); // Switch the mode manually (only sticks while auto-switching is disabled). Button mobile = new Button("Set mobile", ev -> chatAssistant.setMode(ChatAssistantMode.MOBILE)); - Button desktop = new Button("Set desktop", ev -> chatAssistant.setMode(ChatAssistantMode.DESKTOP)); + Button desktop = + new Button("Set desktop", ev -> chatAssistant.setMode(ChatAssistantMode.DESKTOP)); // Freeze/resume automatic switching on the configured breakpoint. - Button toggleSwitching = new Button("Toggle auto switching", ev -> { - chatAssistant.setMobileModeSwitchingEnabled(!chatAssistant.isMobileModeSwitchingEnabled()); - Notification.show("Auto switching: " + chatAssistant.isMobileModeSwitchingEnabled()); - }); + Button toggleSwitching = + new Button( + "Toggle auto switching", + ev -> { + chatAssistant.setMobileModeSwitchingEnabled( + !chatAssistant.isMobileModeSwitchingEnabled()); + Notification.show("Auto switching: " + chatAssistant.isMobileModeSwitchingEnabled()); + }); // Move the FAB back to its configured corner after it has been dragged. Button reset = new Button("Reset FAB position", ev -> chatAssistant.resetFabPosition()); @@ -81,13 +84,12 @@ public ChatAssistantModeDemo() { // Seed the conversation with a greeting and open the window. chatAssistant.sendMessage( - Message.builder() - .content("Resize the window to switch modes.") - .messageTime(LocalDateTime.now()) - .name("Assistant") - .avatar("chatbot.png") - .build() - ); + Message.builder() + .content("Resize the window to switch modes.") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build()); chatAssistant.setOpened(true); add(controls, chatAssistant); diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomChatMessage.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomChatMessage.java index 93a8220..b159f55 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomChatMessage.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomChatMessage.java @@ -1,9 +1,28 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ package com.flowingcode.vaadin.addons.chatassistant; import com.vaadin.flow.component.html.Span; public class CustomChatMessage extends ChatMessage { - + private Span tagline = new Span(); public CustomChatMessage(CustomMessage message) { @@ -13,7 +32,4 @@ public CustomChatMessage(CustomMessage message) { tagline.getStyle().set("font-size", "x-small"); this.add(tagline); } - - - } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomMessage.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomMessage.java index f052dc6..2b3d1c2 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomMessage.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomMessage.java @@ -1,3 +1,22 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ package com.flowingcode.vaadin.addons.chatassistant; import com.flowingcode.vaadin.addons.chatassistant.model.Message; @@ -11,7 +30,6 @@ @Setter @SuperBuilder public class CustomMessage extends Message { - + private String tagline; - } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/DemoView.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/DemoView.java index 4dac2b2..9908fbb 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/DemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/DemoView.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/AbstractViewTest.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/AbstractViewTest.java index 8ee9f3e..f313d7c 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/AbstractViewTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/AbstractViewTest.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/BasicIT.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/BasicIT.java index 9d118de..91e30ac 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/BasicIT.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/BasicIT.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -41,7 +41,7 @@ public void sendMessageFromUser() { String notificationMessage = $(NotificationElement.class).waitForFirst().getText(); Assert.assertEquals("hello", notificationMessage); } - + @Test public void sendMessageFromAssistant() { ChatAssistantElement element = $(ChatAssistantElement.class).first(); diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/ViewIT.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/ViewIT.java index 7f6787b..d6cfa21 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/ViewIT.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/ViewIT.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -42,7 +42,8 @@ public void describeTo(Description description) { @Override protected boolean matchesSafely(TestBenchElement item, Description mismatchDescription) { - String script = "let s=arguments[0].shadowRoot; return !!(s&&s.childElementCount)"; + String script = + "return !!window.customElements.get(arguments[0].tagName.toLowerCase())"; if (!item.getTagName().contains("-")) { return true; } @@ -58,7 +59,7 @@ protected boolean matchesSafely(TestBenchElement item, Description mismatchDescr @Test public void componentWorks() { - TestBenchElement element = $("chat-bot").first(); + TestBenchElement element = $("animated-fab").first(); MatcherAssert.assertThat(element, hasBeenUpgradedToCustomElement); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/po/ChatAssistantElement.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/po/ChatAssistantElement.java index dac91a5..7cccf4d 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/po/ChatAssistantElement.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/po/ChatAssistantElement.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,5 +22,5 @@ import com.vaadin.testbench.TestBenchElement; import com.vaadin.testbench.elementsbase.Element; -@Element("chat-bot") +@Element("animated-fab") public class ChatAssistantElement extends TestBenchElement {} diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/po/ChatBubbleElement.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/po/ChatBubbleElement.java index ad46534..c28e901 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/po/ChatBubbleElement.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/po/ChatBubbleElement.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/test/SerializationTest.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/test/SerializationTest.java index a97d46f..cb919e6 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/test/SerializationTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/test/SerializationTest.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -45,8 +45,10 @@ private void testSerializationOf(Object obj) throws IOException, ClassNotFoundEx @Test public void testSerialization() throws ClassNotFoundException, IOException { try { - ChatAssistant chatAssistant = new ChatAssistant(); + ChatAssistant chatAssistant = new ChatAssistant<>(); chatAssistant.sendMessage(Message.builder().build()); + // Register a screen-size listener so the listener-map state is covered by serialization. + chatAssistant.addScreenSizeListener(300, null, ev -> {}); testSerializationOf(chatAssistant); } catch (Exception e) { Assert.fail("Problem while testing serialization: " + e.getMessage()); diff --git a/src/test/resources/META-INF/frontend/styles/chat-assistant-styles-demo.css b/src/test/resources/META-INF/frontend/styles/chat-assistant-styles-demo.css index d161b31..7ff15c7 100644 --- a/src/test/resources/META-INF/frontend/styles/chat-assistant-styles-demo.css +++ b/src/test/resources/META-INF/frontend/styles/chat-assistant-styles-demo.css @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,11 +17,11 @@ * limitations under the License. * #L% */ - chat-bot { + animated-fab { --chatbot-avatar-img: url(chatbot.png); --chatbot-avatar-bg-color: white; } - - chat-bot.small vaadin-message { + + animated-fab.small vaadin-message { font-size: var(--lumo-font-size-xxs); } diff --git a/src/test/resources/META-INF/resources/chatbot.svg b/src/test/resources/META-INF/resources/chatbot.svg index 5acc55a..159f2ab 100644 --- a/src/test/resources/META-INF/resources/chatbot.svg +++ b/src/test/resources/META-INF/resources/chatbot.svg @@ -1,4 +1,24 @@ + + Date: Thu, 23 Jul 2026 11:55:21 -0300 Subject: [PATCH 19/25] fix: portal FAB to body so it escapes ancestor containing blocks Fixes the FAB being trapped by an ancestor that establishes a containing block for fixed descendants (e.g. Aura AppLayout navbar backdrop-filter): the anchored wrapper is lifted to document.body so position:fixed resolves against the viewport, and restored to its slot on detach. Close #70 --- .../addons/chatassistant/ChatAssistant.java | 13 ++++++- .../frontend/fc-chat-assistant-movement.js | 37 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java index 87ad349..ac7e5f6 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java @@ -375,7 +375,10 @@ private void setUI( .setDisplay(Style.Display.INLINE_FLEX) .setAlignItems(Style.AlignItems.CENTER) .setJustifyContent(Style.JustifyContent.CENTER) - .setPosition(Style.Position.FIXED); + .setPosition(Style.Position.FIXED) + // Kept below Vaadin overlays (z-index ~200, so the popover/dialog layer above) but above + // page content, since an anchored FAB is portaled to document.body. + .set("z-index", "100"); // Apply the FAB diameter and icon size (the single sizing code path). setFabSize(DEFAULT_FAB_SIZE); @@ -932,6 +935,14 @@ public void setFabAnchoredToViewport(boolean anchoredToViewport) { } else { fab.getElement().removeAttribute("anchored"); } + // An anchored FAB is lifted to document.body so its position:fixed clears any ancestor + // containing block (a transformed/filtered container traps a fixed descendant otherwise); + // un-anchoring returns it to its container. Idempotent with the portal done at movement init. + fabWrapper + .getElement() + .executeJs( + "window.fcChatAssistantPortalFab && window.fcChatAssistantPortalFab(this, $0)", + anchoredToViewport); } /** diff --git a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js index 4e9e598..b5b1984 100644 --- a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js +++ b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js @@ -36,6 +36,37 @@ if (!customElements.get('animated-fab')) { }); } +// Lifts the FAB wrapper to while it is anchored to the viewport, so its position:fixed +// resolves against the viewport rather than an ancestor containing block. Any ancestor with +// transform/filter/backdrop-filter/perspective/contain/will-change (e.g. Aura's AppLayout navbar) +// would otherwise trap the wrapper. When it is not anchored, the wrapper is returned to its home +// slot so position:absolute stays relative to its container. Idempotent. +window.fcChatAssistantPortalFab = (item, anchored) => { + if (anchored) { + if (item.parentNode !== document.body) { + // Remember where the wrapper lived so it can be put back on teardown / un-anchor. + item.__fcHome = item.__fcHome || { parent: item.parentNode, next: item.nextSibling }; + document.body.appendChild(item); + } + } else if (item.parentNode === document.body) { + fcChatAssistantRestoreFab(item); + } +}; + +// Returns the wrapper to its recorded home slot (falling back to appendChild if the recorded next +// sibling is gone), so a detached host takes the wrapper with it instead of leaking it in . +function fcChatAssistantRestoreFab(item) { + const home = item.__fcHome; + if (home && home.parent) { + if (home.next && home.next.parentNode === home.parent) { + home.parent.insertBefore(item, home.next); + } else { + home.parent.appendChild(item); + } + } + item.__fcHome = null; +} + // Resolves the FAB's rendered size, falling back to the offset/CSS size when the element has not // been laid out yet (getBoundingClientRect returns 0 before the first layout pass). function fcChatAssistantSize(fab) { @@ -108,11 +139,17 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po window.removeEventListener("resize", resizeHandler); window.fcChatAssistantMobileModeOff?.(root); window.fcChatAssistantScreenSizeOffAll?.(root); + // Put the wrapper back in its home slot so it is removed with the detached host, not left + // behind in ; on reattach this function re-runs and re-portals it. + fcChatAssistantRestoreFab(item); // Clear the init guards so movement re-initializes on reattach. item[guard] = false; root['fc-chat-assistant-drag-listener'] = null; }); + // Escape any ancestor containing block by portaling the wrapper to while anchored. + window.fcChatAssistantPortalFab(item, fab.hasAttribute('anchored')); + // Update FAB position function updatePosition() { item.style.right = position.x + 'px'; From 4a0bd40b03aefa6e4d9a79724c6199fa2211fc05 Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Wed, 19 Aug 2026 11:24:55 -0300 Subject: [PATCH 20/25] fix: clear stale message metadata on recycled rows setMessage skipped the setter for a null name, avatar or message time without removing the previously set attribute, so a recycled VirtualList row could keep showing another message's metadata. Each attribute is now cleared in its null branch, and the avatar is handled independently of the name. --- .../vaadin/addons/chatassistant/ChatMessage.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java index 9370cd6..4bc1107 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java @@ -84,16 +84,25 @@ public ChatMessage(T message, boolean markdownEnabled) { public void setMessage(T message) { this.message = message; updateMessage(message); + // Each attribute is cleared in its null branch so a recycled VirtualList row does not keep + // showing the previous message's metadata. The avatar is handled independently of the name + // because a message can carry one without the other. if (message.getName() != null) { this.setUserName(message.getName()); - if (message.getAvatar() != null) { - this.setUserImg(message.getAvatar()); - } + } else { + getElement().removeAttribute("user-name"); + } + if (message.getAvatar() != null) { + this.setUserImg(message.getAvatar()); + } else { + getElement().removeAttribute("user-img"); } if (message.getMessageTime() != null) { String formattedTime = message.getMessageTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); this.setTime(formattedTime); + } else { + getElement().removeAttribute("time"); } } From a844e95b7f77e6b284258f27a58d37e5a576471b Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Wed, 19 Aug 2026 11:25:21 -0300 Subject: [PATCH 21/25] fix: keep Flow child insertion valid while the FAB is portaled Flow derives the insertion reference for a new child from the DOM sibling that follows the preceding state-tree child. While the anchored FAB wrapper is lifted into , that lookup can yield a body-level node, and .insertBefore would throw NotFoundError. The element now falls back to appending when the reference is not one of its children. --- .../resources/frontend/fc-chat-assistant-movement.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js index b5b1984..850c95b 100644 --- a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js +++ b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js @@ -23,6 +23,18 @@ // disconnectedCallback, leaving the window/observer listeners uncleaned and the init guards set. if (!customElements.get('animated-fab')) { customElements.define('animated-fab', class extends HTMLElement { + // Flow derives the insertion reference for a new child from the DOM sibling that follows the + // preceding state-tree child (SimpleElementBindingStrategy#addChildren). While + // fcChatAssistantPortalFab keeps the FAB wrapper lifted into , that lookup can yield a + // -level node, and insertBefore would then throw NotFoundError. Appending instead is + // the right fallback: a reference outside this element marks no position within it. + insertBefore(node, reference) { + if (reference && reference.parentNode !== this) { + return super.appendChild(node); + } + return super.insertBefore(node, reference); + } + disconnectedCallback() { (this.__fcCleanups || []).forEach(fn => { try { From e91b3980e228c1de56a878323ad01a6497d424e3 Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Wed, 19 Aug 2026 11:25:32 -0300 Subject: [PATCH 22/25] fix: measure one box in both screen-size deliveries The registration delivery read getBoundingClientRect (border box) while the ResizeObserver callback read contentRect (content box). The observed container carries padding in desktop mode, so a threshold close to the current size reported one state on registration and the opposite one on the first observer callback. Both paths now read the border box. --- .../resources/frontend/fc-chat-assistant-movement.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js index 850c95b..120895a 100644 --- a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js +++ b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js @@ -352,7 +352,11 @@ window.fcChatAssistantScreenSize = (root, overlayDiv, key, widthRaw, heightRaw) } }; - entry.observer = new ResizeObserver((entries) => notify(entries[0].contentRect)); + // Both deliveries measure the same box: the observer's own contentRect excludes the padding that + // `container` carries in desktop mode, so mixing it with getBoundingClientRect() below would let + // a threshold near the current size report one state on registration and the opposite one on the + // first observer callback. + entry.observer = new ResizeObserver(() => notify(overlayDiv.getBoundingClientRect())); entry.observer.observe(overlayDiv); root.__fcScreenSizeListeners[key] = entry; // Deliver the current state immediately (if already laid out; otherwise the observer's first From a166b4f3606c97e3e3e53a91eda6c8d619df0113 Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Wed, 19 Aug 2026 11:25:32 -0300 Subject: [PATCH 23/25] fix: scope the popover radius rule to the chat assistant The border-radius rule matched every vaadin-popover-overlay, and because this stylesheet is applied through @CssImport it restyled every popover in the host application. It is now scoped with the add-on class, like the other rules in the file, and covers the Vaadin 24 and Vaadin 25 tags. --- .../resources/frontend/styles/fc-chat-assistant-style.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css index d6efa65..e4b547e 100644 --- a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css +++ b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css @@ -42,7 +42,11 @@ transition: all 0.12s ease-out; } -vaadin-popover-overlay::part(overlay) { +/* Scoped with the add-on class so this @CssImport does not restyle every popover in the host + application. The Vaadin 24 (vaadin-popover-overlay) and Vaadin 25 (vaadin-popover) selectors + share one rule. */ +vaadin-popover-overlay.fc-chat-assistant-popover::part(overlay), +vaadin-popover.fc-chat-assistant-popover::part(overlay) { /* Cross-theme fallback: Lumo radius token, then Aura base radius, then a literal. */ border-radius: var(--lumo-border-radius-l, var(--aura-base-radius, 12px)); } From acbff15c799b3c7a7e37fdf73a311a56d4684f4b Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Wed, 19 Aug 2026 12:24:37 -0300 Subject: [PATCH 24/25] fix: disconnect the FAB sizing observer on teardown The IntersectionObserver that waits for the FAB to get a real size only disconnected from inside its own callback, so a host detaching while the FAB was still 0x0 (for instance in a tab that is never shown) left the observer alive holding a reference to it. Register the disconnect in the module's cleanup list. --- .../META-INF/resources/frontend/fc-chat-assistant-movement.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js index 120895a..d60f9f8 100644 --- a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js +++ b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js @@ -266,6 +266,10 @@ window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, po } }); observer.observe(fab); + // The callback only disconnects once the FAB has a real size, so a host that detaches while + // the FAB is still 0x0 (e.g. in a tab that is never shown) would leave the observer holding + // a reference to it. Tear it down with the rest of the module's cleanups. + root.__fcCleanups.push(() => observer.disconnect()); } updatePosition(); From 2e1d855ccf3d70359509cdc7feb40e828b6a300d Mon Sep 17 00:00:00 2001 From: Leonardo Scardanzan Date: Wed, 19 Aug 2026 12:24:37 -0300 Subject: [PATCH 25/25] docs: correct the resize-arrow specificity note The comment claimed the hide rule matches the show rule's specificity and wins on source order. It actually matches one class more, so it wins on specificity alone; the old note could mislead a later reordering. The CSS is unchanged. --- .../resources/frontend/styles/fc-chat-assistant-style.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css index e4b547e..ae879c3 100644 --- a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css +++ b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css @@ -111,7 +111,8 @@ vaadin-dialog-overlay.fc-chat-assistant-dialog::part(content) { } /* While actively resizing, the handle grows and would push the arrowhead outside the overlay; hide it. - Specificity matches the show rule above and comes later, so it wins. */ + This rule matches one class more than the show rule above, so it wins on specificity + regardless of source order. */ .fc-chat-assistant-resize-indicator-visible .fc-chat-assistant-resize-resizing.fc-chat-assistant-resize-can-drag .fc-chat-assistant-resize-arrow {