From 467a751ad1399d9814e390a4374e51e1c0fe225b Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:05:18 -0300 Subject: [PATCH 01/19] 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 6f66d4c5362e64066fab9d8863accbd5527dcb45 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:05:18 -0300 Subject: [PATCH 02/19] build: remove the markdown-editor-addon dependency --- pom.xml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pom.xml b/pom.xml index 9f197eb..673075c 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.40 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 dd971992118706da0d5459235fd19cfd8b3c0b1a Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:05:18 -0300 Subject: [PATCH 03/19] 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 673075c..0a80d2f 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 c67d707e0c9198c824c59c2b8e8da35a5cc7ad77 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:05:29 -0300 Subject: [PATCH 04/19] 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 d58c8ac86d4d8d24dcbd8ea76c1f7b5bc4a55a53 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:05:30 -0300 Subject: [PATCH 05/19] 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 ad1701cf4d4db284a94ec42b5e501cc15881b65d Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:05:30 -0300 Subject: [PATCH 06/19] 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 16004b6b5646da6054c09b3e59305a27199eaf7f Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:05:42 -0300 Subject: [PATCH 07/19] 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 101ddf9247c77750298e466b7306c29f31452a34 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:05:42 -0300 Subject: [PATCH 08/19] 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 fd96c16fc0a9aee19a7ee64a593d842bf8439f5f Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:03 -0300 Subject: [PATCH 09/19] 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 c21696f38231ac938a6fd0f25f7319330e582e2a Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:03 -0300 Subject: [PATCH 10/19] 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 a5d1a981a210eb2ed8170bd9022f4ece6a588b50 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:03 -0300 Subject: [PATCH 11/19] 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 65c3d736ab676a830ffd38256d1314ac8d16ed4f Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:03 -0300 Subject: [PATCH 12/19] 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 d37651c23d929f049bebf00ca57782796b133af9 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:22 -0300 Subject: [PATCH 13/19] 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 f9a84a69c47d1cc54d7fbeebdf6b6a2f9d3dfced Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:22 -0300 Subject: [PATCH 14/19] 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 da49b2c9fd3106ecca41fe86f89c0e3e06477e5f Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:22 -0300 Subject: [PATCH 15/19] 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 fcf8b545d29b51694d8292d2eafa587821e7ed19 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:22 -0300 Subject: [PATCH 16/19] 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 39def6ae199016ffba0b734819425ed6ccde8339 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:22 -0300 Subject: [PATCH 17/19] 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 b65ec106494ae3a4c37a163253e75b5d362882f3 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Wed, 1 Jul 2026 10:12:16 -0300 Subject: [PATCH 18/19] refactor: add classname to markdown selector WIP: address FAB API and behavior review feedback Introduce a FabVariant enum for FAB theming. Make isFabMovable() report the effective current state. Enforce a single ChatAssistant per UI. Use --lumo-warning-contrast-color as the badge text. Extract the --fc-min/max-* CSS property names into constants. Drop the duplicate setOpenOnClick(false). Add @since 5.1.0 tags to the new public API. WIP: fix frontend listener cleanup and stale overlay references WIP: add unit tests for ChatAssistant pure-Java logic Cover parseFabMargin fallbacks, setUnreadMessages clamping to 0-99, addScreenSizeListener argument validation, and the FabVariant to ButtonVariant mapping, in the lightweight style of SerializationTest. WIP: add window sizing options to the builder Expose width, height, maxWidth and maxHeight on the ChatAssistant builder so the initial window size and its max bounds can be configured at construction time. WIP: apply Google Java Style WIP: normalize copyright year range WIP: support both Lumo and Aura themes Make the FAB color variants cross-theme: SUCCESS and ERROR now also carry an Aura accent CSS class (aura-accent-green/red) alongside the Lumo theme variant, since Aura styles accent colors via class rather than the theme attribute; PRIMARY stays on the cross-theme primary token and LUMO_CONTRAST is documented as Lumo-only. Replace the hardcoded --lumo-* tokens on the unread badge and in fc-chat-assistant-style.css (shadows, border radius, contrast) with cross-theme fallback chains (var(--lumo-x, var(--aura-y, ))), mirroring the pattern already used in fc-chat-message-styles.css. WIP: report effective FAB drag state from isFabMovable isFabMovable() now returns fabMovable && fabAnchoredToViewport, matching the client drag gate (movable && anchored). A container-anchored FAB cannot be dragged, so the getter no longer reports true in that configuration; it already returned false in mobile mode. Adds a unit test covering the anchored/movable combinations. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: fix background-thread hygiene in demos ChatAssistantDemo replaces the leaked java.util.Timer (non-daemon, never cancelled) with a daemon-threaded ScheduledExecutorService that is shut down on detach. ChatAssistantGenerativeDemo runs its streaming task on a dedicated daemon executor instead of the shared ForkJoinPool.commonPool(), so its blocking sleeps no longer tie up common-pool threads; the executor is shut down on detach. Streaming behaviour and UI.access updates are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: simplify public javadoc, drop client-internal details Rewrite the javadoc on isFabMovable, setResizeIndicatorsVisible and the FabVariant enum so it describes behaviour from the caller's point of view rather than leaking client-side implementation details (the movable/anchored attributes, the Aura CSS-class-vs-theme-attribute mechanism, and Lumo design tokens). No behavioural change. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: encapsulate FabVariant theming internals Make the ButtonVariant mapping and Aura CSS class private to FabVariant and expose intent-level applyColorTo(Button)/removeColorFrom(Button) instead of the toButtonVariant()/getAuraClass() accessors, so the underlying ButtonVariant and theme-class details no longer leak into the public API. ChatAssistant delegates color application to the variant; size handling is unchanged. Tests now verify the applied theme variant and Aura class through a real Button. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: clarify only default functionality comply WIP: fix invalid CSS rejected by lightningcss (Vaadin 25.2) Check that the css follows the rules of lightningcss. Close #69 Close #67 WIP: make introduced variables private instead of protected WIP: store FAB icon once as a single Component field Merge the redundant fabIcon/fabIconComponent fields into a single Component field, applying instanceof checks only where an SvgIcon is specifically required. Addresses PR #68 review r3545904693. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: reset unread badge text color on whitespace-only input setUnreadBadgeColors guarded the background with isBlank() but the text color with isEmpty(), so a whitespace-only color was applied verbatim instead of falling back to the default. Use isBlank() for both. Addresses PR #68 review r3546279320. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: clear recycled message row content while loading updateMessage gated all content rewriting behind !isLoading(), so when the ComponentRenderer recycled a pooled VirtualList row to show a loading placeholder the previous message's text/markdown stayed visible next to the spinner. Always rewrite the content, blanking it while loading. Addresses PR #68 review r3546279308. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: make screen-size listener work in mobile mode addScreenSizeListener always observed the desktop popover overlay Div, but in MOBILE mode the chat content is moved into the mobile Dialog and the overlay is empty (0x0), so the listener never fired. Observe the container surface instead (it moves between both windows and a ResizeObserver follows it across DOM moves), and re-deliver the size on mobile dialog open for parity with the popover. Addresses PR #68 review r3546279298. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: load default FAB icon lazily and degrade gracefully The default chatbot SVG was read in a static-final initializer, so a missing/unreadable resource threw at class-load time (ExceptionInInitializer Error, then NoClassDefFoundError on every later reference) even for callers supplying their own icon. Load it lazily on first use, cache the result, and fall back to no icon with a warning instead of throwing. Addresses PR #68 review r3546279303. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: reject non-pixel lengths in window-size setters The client clamps window sizes numerically (parseFloat(value) + "px"), so a relative unit like "50%" or "30vw" passed to the String size setters was silently reinterpreted as pixels and clamped to the min bound. Validate the String width/height/min/max setters up front and fail with a clear message, matching the documented absolute-units contract. Addresses PR #68 review r3546279289. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: keep duplicate ChatAssistant inert instead of throwing on attach Attaching a second ChatAssistant to a UI threw IllegalStateException, which could crash attach-before-detach route navigation or a dialog-over-view. Log a warning and leave the extra instance hidden and unwired instead, so it never competes with the already-attached instance and navigation is not broken. Addresses PR #68 review r3546279283. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: re-init FAB movement after reattach movement.js disconnectedCallback removed the resize listener but never cleared the __fcChatAssistantMovement / fc-chat-assistant-drag-listener init guards, so after a detach/reattach addComponentRefreshedListener skipped re-initialization and the resize handler was never re-added (FAB could end up stranded off-screen). Clear both guards on disconnect. Addresses PR #68 review r3546279330. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: re-init resize handles after reattach resize.js disconnectedCallback tore down the window resize listener, style observer and overlay-lookup timeout but never cleared the per-direction init guard on the durable resizer Div (nor the server refresh guard), so on reattach fcChatAssistantResize early-returned and the resize handlers/ indicators were never re-established. Clear both guards on disconnect (which only fires on a genuine detach, not on popover reopen). Addresses PR #68 review r3546279335. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: make builder constructor private and use primitive flags The all-args builder constructor was public with many boxed Boolean flags that had to be null-defaulted in setUI. Make it private (construction is via ChatAssistant.builder() or the public legacy constructors) and switch the flags to primitive boolean so callers cannot pass null. A small partial ChatAssistantBuilder seeds the real defaults for the flags whose default is not false, and setUI no longer null-coalesces. Addresses PR #68 review r3545748934. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: make internal resizer styling helpers private applyGenericResizerStyle and setResizerClass are internal helpers used only within ChatAssistant to build/toggle the resize handles; they need not be part of the protected extension surface. Make them private. Addresses PR #68 review r3546022009. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: inline size-variant check in FAB theme variant methods add/removeFabThemeVariants branched on variant.isSizeVariant() and then assumed the variant was LARGE or SMALL (variant == LARGE ? ... : SMALL). Inline the explicit SMALL/LARGE check so that assumption is provable at the call site and a future non-size variant cannot fall into the size branch. isSizeVariant() remains as public API. Addresses PR #68 review r3545985941. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: use diamond operator in SerializationTest Drop the redundant explicit type argument on the right-hand side; the target type already provides it, per the style guide's diamond preference. Addresses PR #68 review r3546149078. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: merge duplicated popover/dialog content-part CSS rules The popover ::part(content) declaration block was duplicated across the Vaadin 24 (vaadin-popover-overlay) and Vaadin 25 (vaadin-popover) selectors, and likewise for the dialog pair. Combine each pair into a single comma-separated rule (valid under lightningcss) so future tweaks live in one place. Addresses PR #68 review r3546279367. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: share overlay resolution between resize.js helpers fetchOverlay and fcChatAssistantContentPart each hardcoded the same overlay selector chain (popover class -> shadowRoot -> vaadin-popover-overlay, with a getElementsByClassName fallback). Extract a single resolveOverlay(popoverTag) helper so the DOM structure is described once. Addresses PR #68 review r3546279356. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: deduplicate setFabIcon overloads Both setFabIcon(Component) and setFabIcon(Component, int) repeated the icon assignment, fab.setIcon and applyIconSize sequence. Extract a private applyFabIcon(icon, size) helper. The no-size overload keeps its own path (getFabIconSize() can be 0, which is valid here but rejected by the sized overload's size > 0 guard). Addresses PR #68 review r3546279361. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: defer window size/constraint push until the popover is open Each of the six window size/bound setters ran an executeJs that polled the popover content part up to 20x100ms; at construction the popover is closed, so a fully-configured builder spun ~6 pointless ~2s polling loops. Store the desired size in the --fc-* custom properties on the durable overlay Div from Java and only push to the client when the popover is open (the open listener already re-applies everything via fcChatAssistantRestoreWindowSize). The now-unused fcChatAssistantSetWindowSize JS helper is removed. Addresses PR #68 review r3546279350. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: hold resize handles in a direction-keyed map The eight resizer Divs were separate fields enumerated across three sites (edge styling, resizable class toggling, and client registration). Replace them with a single Map keyed by direction plus a RESIZER_DIRECTIONS list, and iterate that one collection at each site, so adding or renaming a handle is a single-line change. Per-direction edge styling moves into applyResizerEdgeStyle. Addresses PR #68 review r3546279344. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: run frontend teardown via an animated-fab custom element The root element has no backing web component, so the root.disconnectedCallback assigned by the movement and resize modules was never invoked by the browser: the window resize listeners, style observer, overlay-lookup timeout and screen-size/mobile observers leaked on detach and the init guards stayed set (blocking reattach). Register a minimal animated-fab custom element whose disconnectedCallback runs teardown callbacks accumulated on root.__fcCleanups, and have both modules push their cleanup there. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: delegate setFabIcon(Component) to the sized overload setFabIcon(Component) now calls setFabIcon(icon, getFabIconSize()) instead of duplicating the assignment/install/size logic. getFabIconSize() returns at least 1px so it never trips the sized overload's size > 0 guard when fabSize is small. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: don't nudge the FAB on a sub-threshold click pointermove mutated position before the sensitivity check returned, so a click with tiny movement was committed by stopDragging -> snapToBoundary -> updatePosition, shifting the FAB. Compute a candidate position and only commit it (and update the DOM) once the move exceeds the drag sensitivity. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: reject negative FAB margin parseFabMargin accepted a negative value (e.g. "-5"), which would pull the FAB off the edge. Fall back to the default for negatives, and cover it with a test. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: abort generative demo streaming on interrupt streamWords swallowed InterruptedException (re-set the flag but kept emitting), so after onDetach's executor.shutdownNow() the loop kept calling ui.access on a detached view. Replace the stream with a plain loop whose Thread.sleep propagates the interrupt and aborts streaming. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: fix FAB theme-variant example in README addFabThemeVariants takes FabVariant, not ButtonVariant. Use FabVariant.LARGE/LUMO_CONTRAST in the snippet and correct the feature-list wording so the sample compiles. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: correct @since tags on window sizing/unread API Remove @since 5.1.0 from the String window-size setters, getUnreadMessages and setUnreadMessages(int) (all present since 5.0.0), and add @since 5.1.0 to setWindowResizable/isWindowResizable, which are new in 5.1.0 (also normalizing the malformed isWindowResizable javadoc). Verified against tag chat-assistant-addon-5.0.0. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: move FAB color application out of FabVariant FabVariant.applyColorTo/removeColorFrom were public methods that mutated a Button, leaking the apply logic. Make FabVariant a data-only enum exposing getButtonVariant()/getAuraClass(), and apply/remove the color from private ChatAssistant methods. Tests now assert the variant's data mapping. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: fill empty @param in ChatMessage.updateMessage javadoc Co-Authored-By: Claude Opus 4.8 (1M context) WIP: trim redundant javadoc and reflow mangled comments Remove the Lombok-internals explanation on ChatAssistantBuilder, the breaking-change rationale on the mobile breakpoint, and the redundant single-instance comment in onAttach (the code and warning already say it). Reflow comments that had been wrapped into orphaned one-word lines (these render verbatim in the @DemoSource viewer). Co-Authored-By: Claude Opus 4.8 (1M context) WIP: drop write-only minWidth/minHeight fields The minWidth/minHeight fields were only ever assigned; the real state is the --fc-min-width/--fc-min-height custom properties on the overlay Div (read by fcChatAssistantApplyConstraints). Remove the dead fields and set the custom properties directly. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: target the animated-fab tag in integration tests The component root tag is animated-fab, but the ViewIT selector, the ChatAssistantElement page object and the demo CSS still referenced the old chat-bot tag. Update them so the ITs resolve the component (and, with the custom-element registration, ViewIT's upgrade assertion now holds). Co-Authored-By: Claude Opus 4.8 (1M context) WIP: cover screen-size listener state in SerializationTest Register an addScreenSizeListener before serializing so the screen-size listener map (ScreenSizeListenerEntry) is exercised by the round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: simplify px concatenation and add a color literal fallback Drop the redundant String.valueOf(...) in the int window-size setters (int + "px" already yields a String), and add a literal fallback to the who-is-typing color var chain so it degrades when neither the Lumo nor Vaadin token is defined. Co-Authored-By: Claude Opus 4.8 (1M context) WIP: assert animated-fab upgrade instead of shadow-root content Co-Authored-By: Claude Opus 4.8 (1M context) WIP: accept any CSS length for window size, drop px-only validation Co-Authored-By: Claude Opus 4.8 (1M context) WIP: make internal FAB helpers private Co-Authored-By: Claude Opus 4.8 (1M context) WIP: place @since before @param/@return per FC javadoc convention Co-Authored-By: Claude Opus 4.8 (1M context) WIP: document FAB movable no-op and defensive message copy Co-Authored-By: Claude Opus 4.8 (1M context) --- 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/19] 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';