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

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

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

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

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

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

+ * When disabled, the component is left in whatever mode it is currently in (freeze), and the mode + * can only be changed manually via {@link #setMode(ChatAssistantMode)}. When enabled, the + * breakpoint is evaluated against the current viewport width. If no breakpoint was configured, the + * default ({@value #DEFAULT_MOBILE_BREAKPOINT}px) is used. + * + * @param enabled {@code true} to switch automatically on viewport changes, {@code false} to freeze + */ + public void setMobileModeSwitchingEnabled(boolean enabled) { + if (this.mobileModeSwitchingEnabled == enabled) { + return; + } + this.mobileModeSwitchingEnabled = enabled; + if (enabled) { + this.getElement().executeJs( + "window.fcChatAssistantMobileMode($0, $1);", this.getElement(), this.mobileBreakpoint); + } else { + this.getElement().executeJs("window.fcChatAssistantMobileModeOff($0);", this.getElement()); + } + } + + /** + * Returns whether automatic switching between mobile and desktop mode is enabled. + * + * @return {@code true} if automatic switching is enabled + */ + public boolean isMobileModeSwitchingEnabled() { + return mobileModeSwitchingEnabled; + } } From 1e3c062b118d8f3e27258d4945f0b747fbe80807 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:03 -0300 Subject: [PATCH 09/25] feat(demo): rewrite core and content demos Commented basic/lazy-loading/markdown/generative demos using the current API. --- .../chatassistant/ChatAssistantDemo.java | 104 +++++--- .../ChatAssistantGenerativeDemo.java | 149 ++++++----- .../ChatAssistantLazyLoadingDemo.java | 235 ++++++++++-------- .../ChatAssistantMarkdownDemo.java | 43 +++- 4 files changed, 309 insertions(+), 222 deletions(-) diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java index 076ca59..32056c0 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java @@ -7,9 +7,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -23,11 +23,9 @@ import com.flowingcode.vaadin.addons.demo.SourcePosition; import com.google.common.base.Strings; import com.vaadin.flow.component.UI; -import com.vaadin.flow.component.avatar.Avatar; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.dependency.CssImport; -import com.vaadin.flow.component.html.Image; -import com.vaadin.flow.component.icon.SvgIcon; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.data.renderer.ComponentRenderer; @@ -35,60 +33,69 @@ import com.vaadin.flow.router.Route; import java.time.LocalDateTime; import java.util.Timer; +import java.util.TimerTask; @DemoSource(sourcePosition = SourcePosition.PRIMARY) @PageTitle("Basic Demo") -@SuppressWarnings("serial") @Route(value = "chat-assistant/basic-demo", layout = ChatAssistantDemoView.class) @CssImport("./styles/chat-assistant-styles-demo.css") public class ChatAssistantDemo extends VerticalLayout { - + public ChatAssistantDemo() { + // Create the assistant with all defaults ChatAssistant chatAssistant = new ChatAssistant<>(); - SvgIcon icon = new SvgIcon("chatbot.svg"); - icon.setColor("var(--lumo-primary-contrast-color)"); - chatAssistant.setFabIcon(icon); + // Set a sensible default window size chatAssistant.setWindowWidth("400px"); chatAssistant.setWindowHeight("400px"); + + // Render each message with a custom component that also shows its tagline. + chatAssistant.setMessagesRenderer(new ComponentRenderer( + CustomChatMessage::new, + (component, message) -> { + ((CustomChatMessage) component).setMessage(message); + return component; + })); + + // Echo messages submitted by the user from the chat input. + chatAssistant.setSubmitListener(se -> chatAssistant.sendMessage(CustomMessage.builder() + .messageTime(LocalDateTime.now()).name("User").content(se.getValue()) + .tagline("Generated by user").build())); + + // Text area used to compose the assistant's answer. TextArea message = new TextArea(); message.setLabel("Enter a message from the assistant"); message.setSizeFull(); - message.addKeyPressListener(ev->{ + message.addKeyPressListener(ev -> { if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); } }); - message.addBlurListener(ev->chatAssistant.clearWhoIsTyping()); - chatAssistant.setMessagesRenderer(new ComponentRenderer(m -> { - return new CustomChatMessage(m); - }, - (component, m) -> { - ((CustomChatMessage) component).setMessage(m); - return component; - })); - chatAssistant.setSubmitListener(se -> { - chatAssistant.sendMessage(CustomMessage.builder().messageTime(LocalDateTime.now()) - .name("User").content(se.getValue()).tagline("Generated by user").build()); - }); + message.addBlurListener(ev -> chatAssistant.clearWhoIsTyping()); + // Send the composed text as an assistant message. Button chat = new Button("Chat"); chat.addClickListener(ev -> { - CustomMessage m = CustomMessage.builder().content(message.getValue()).messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").tagline("Generated by assistant").build(); - - chatAssistant.sendMessage(m); + chatAssistant.sendMessage(CustomMessage.builder().content(message.getValue()) + .messageTime(LocalDateTime.now()).name("Assistant").avatar("chatbot.png") + .tagline("Generated by assistant").build()); message.clear(); }); + + // Send a message in a loading state, then resolve it after 5 seconds. Button chatWithThinking = new Button("Chat With Thinking"); chatWithThinking.addClickListener(ev -> { - CustomMessage delayedMessage = CustomMessage.builder().loading(true).content(message.getValue()) - .messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").tagline("Generated by assistant").build(); - - UI currentUI = UI.getCurrent(); + CustomMessage delayedMessage = CustomMessage.builder() + .loading(true) + .content(message.getValue()) + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build(); chatAssistant.sendMessage(delayedMessage); - new Timer().schedule(new java.util.TimerTask() { + UI currentUI = UI.getCurrent(); + new Timer().schedule(new TimerTask() { @Override public void run() { currentUI.access(() -> { @@ -97,15 +104,36 @@ public void run() { }); } }, 5000); - message.clear(); }); - chatAssistant.sendMessage(CustomMessage.builder().content("Hello, I am here to assist you") + + Button unread = new Button("Toggle unread badge"); + unread.addClickListener(ev -> { + if (chatAssistant.getUnreadMessages() > 0) { + chatAssistant.setUnreadMessages(0); + } else { + chatAssistant.setUnreadMessages((int) (Math.random() * 98) + 1); + } + }); + + // Seed the conversation + chatAssistant.sendMessage( + CustomMessage.builder() + .content("Hello, I am here to assist you") .messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").tagline("Generated by assistant").build()); + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant").build() + ); + + HorizontalLayout row = new HorizontalLayout(chat, chatWithThinking, unread); + row.setSpacing(true); + row.setPadding(false); + row.getStyle().set("flex-wrap", "wrap"); - chatAssistant.setUnreadMessages(4); + unread.click(); + chatAssistant.setOpened(true); - add(message, chat, chatWithThinking, chatAssistant); + add(message, row, chatAssistant); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java index f8afa9e..b1cb3ae 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java @@ -7,9 +7,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -23,11 +23,10 @@ import com.flowingcode.vaadin.addons.demo.SourcePosition; import com.google.common.base.Strings; import com.vaadin.flow.component.UI; -import com.vaadin.flow.component.avatar.Avatar; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.dependency.CssImport; -import com.vaadin.flow.component.html.Image; import com.vaadin.flow.component.icon.SvgIcon; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.data.renderer.ComponentRenderer; @@ -42,108 +41,130 @@ @DemoSource(sourcePosition = SourcePosition.PRIMARY) @PageTitle("Generative Answer Demo") -@SuppressWarnings("serial") @Route(value = "chat-assistant/generative-demo", layout = ChatAssistantDemoView.class) @CssImport("./styles/chat-assistant-styles-demo.css") public class ChatAssistantGenerativeDemo extends VerticalLayout { - + public ChatAssistantGenerativeDemo() { String sampleText = "Hi, I'm an advanced language model. I'm here to help you demonstrate" + " how a text-streaming chat component works in Vaadin. As you can see, each word appears" + " with a slight pause, simulating the time it would take me to \"think\" and generate" + " the next word. I hope this is useful for your demonstration!"; - + + // Create the assistant with all defaults and give it a custom FAB icon. ChatAssistant chatAssistant = new ChatAssistant<>(); SvgIcon icon = new SvgIcon("chatbot.svg"); icon.setColor("var(--lumo-primary-contrast-color)"); chatAssistant.setFabIcon(icon); chatAssistant.setWindowWidth("400px"); chatAssistant.setWindowHeight("400px"); + + // Render each message with a custom component that also shows its tagline. + chatAssistant.setMessagesRenderer(new ComponentRenderer( + CustomChatMessage::new, + (component, message) -> { + ((CustomChatMessage) component).setMessage(message); + return component; + }) + ); + + // Echo messages submitted by the user from the chat input. + chatAssistant.setSubmitListener(se -> chatAssistant.sendMessage( + CustomMessage.builder() + .messageTime(LocalDateTime.now()) + .name("User") + .content(se.getValue()) + .tagline("Generated by user") + .build() + )); + + // Text area pre-filled with the answer that will be streamed. TextArea message = new TextArea(); message.setLabel("Enter a message from the assistant"); message.setSizeFull(); message.setValue(sampleText); - message.addKeyPressListener(ev->{ + message.addKeyPressListener(ev -> { if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); } }); - message.addBlurListener(ev->chatAssistant.clearWhoIsTyping()); - chatAssistant.setMessagesRenderer(new ComponentRenderer(m -> { - return new CustomChatMessage(m); - }, - (component, m) -> { - ((CustomChatMessage) component).setMessage(m); - return component; - })); - chatAssistant.setSubmitListener(se -> { - chatAssistant.sendMessage(CustomMessage.builder().messageTime(LocalDateTime.now()) - .name("User").content(se.getValue()).tagline("Generated by user").build()); - }); + message.addBlurListener(ev -> chatAssistant.clearWhoIsTyping()); + // Send the composed text as a single assistant message. Button chat = new Button("Chat"); chat.addClickListener(ev -> { - CustomMessage m = CustomMessage.builder().content(message.getValue()).messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").tagline("Generated by assistant").build(); - - chatAssistant.sendMessage(m); + chatAssistant.sendMessage( + CustomMessage.builder() + .content(message.getValue()) + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build() + ); message.clear(); }); + + // Send an empty loading message and stream the answer into it word by word. Button chatWithThinking = new Button("Chat With Generative Thinking"); chatWithThinking.addClickListener(ev -> { - String messageToSend = message.getValue(); - CustomMessage delayedMessage = CustomMessage.builder().loading(true).content("") - .messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").tagline("Generated by assistant").build(); - - UI currentUI = UI.getCurrent(); + String answer = message.getValue(); + CustomMessage delayedMessage = CustomMessage.builder() + .loading(true) + .content("") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build(); chatAssistant.sendMessage(delayedMessage); + UI currentUI = UI.getCurrent(); CompletableFuture.runAsync(() -> { try { TimeUnit.MILLISECONDS.sleep(500); - currentUI.access(() -> { - delayedMessage.setLoading(false); - }); - streamWords(messageToSend) - .forEach(item -> { - currentUI.access(() -> { - delayedMessage.setContent(delayedMessage.getContent() + item); - chatAssistant.updateMessage(delayedMessage); - }); - }); + currentUI.access(() -> delayedMessage.setLoading(false)); + streamWords(answer).forEach(word -> currentUI.access(() -> { + delayedMessage.setContent(delayedMessage.getContent() + word); + chatAssistant.updateMessage(delayedMessage); + })); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); - message.clear(); }); - chatAssistant.sendMessage(CustomMessage.builder().content("Hello, I am here to assist you") + + // Seed the conversation with a greeting and open the window. + chatAssistant.sendMessage( + CustomMessage.builder() + .content("Hello, I am here to assist you") .messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").tagline("Generated by assistant").build()); - - add(message, chat, chatWithThinking, chatAssistant); + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build() + ); + chatAssistant.setOpened(true); + + HorizontalLayout row = new HorizontalLayout(chat, chatWithThinking); + row.getStyle().set("flex-wrap", "wrap"); + + add(message, row, chatAssistant); } - - public Stream streamWords(String fullText) { + + /** Splits the text into words and emits them one at a time with a small random delay. */ + private Stream streamWords(String fullText) { if (fullText == null || fullText.isEmpty()) { - return Stream.empty(); + return Stream.empty(); } - - String[] words = fullText.split("\\s+"); - - return Arrays.stream(words) - .map(word -> { - try { - long delay = ThreadLocalRandom.current().nextLong(50, 250); - Thread.sleep(delay); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - - return word + " "; - }); -} - + return Arrays.stream(fullText.split("\\s+")).map(word -> { + try { + Thread.sleep(ThreadLocalRandom.current().nextLong(50, 250)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return word + " "; + }); + } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java index 1d2c639..de0438e 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java @@ -7,9 +7,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -42,88 +42,15 @@ @DemoSource(sourcePosition = SourcePosition.PRIMARY) @PageTitle("Lazy Loading Demo") -@SuppressWarnings("serial") @Route(value = "chat-assistant/lazy-loading-demo", layout = ChatAssistantDemoView.class) @CssImport("./styles/chat-assistant-styles-demo.css") public class ChatAssistantLazyLoadingDemo extends VerticalLayout { - - List messages = new ArrayList<>(Arrays.asList( - Message.builder().name("Claudius").content("I have sent to seek him and to find the body.\n" - + "How dangerous is it that this man goes loose!\n" - + "Yet must not we put the strong law on him.\n" - + "He's loved of the distracted multitude,\n" - + "Who like not in their judgment, but their eyes.\n" - + "And where 'tis so, th' offender's scourge is weighed,\n" - + "But never the offense. To bear all smooth and even,\n" - + "This sudden sending him away must seem\n" - + "Deliberate pause. Diseases desperate grown\n" - + "By desperate appliance are relieved,\n" - + "Or not at all.").build(), - Message.builder().name("Rosencrantz").content("(Enter)").build(), - Message.builder().name("Claudius").content("How now, what hath befall'n?").build(), - Message.builder().name("Rosencrantz").content("Where the dead body is bestowed, my lord,\n" - + "We cannot get from him.").build(), - Message.builder().name("Claudius").content("But where is he?").build(), - Message.builder().name("Rosencrantz").content("Without, my lord; guarded, to know your pleasure.").build(), - Message.builder().name("Claudius").content("Bring him before us.").build(), - Message.builder().name("Rosencrantz").content("Ho, Guildenstern! Bring in my lord.").build(), - Message.builder().name("Claudius").content("Now, Hamlet, where's Polonius?").build(), - Message.builder().name("Hamlet").content("At supper.").build(), - Message.builder().name("Claudius").content("At supper? Where? ").build(), - Message.builder().name("Hamlet").content("Not where he eats, but where he is eaten. A certain \n" - + "convocation of politic worms are e'en at him. Your worm is your \n" - + "only emperor for diet. We fat all creatures else to fat us, and \n" - + "we fat ourselves for maggots. Your fat king and your lean beggar \n" - + "is but variable service- two dishes, but to one table. That's the \n" - + "end.").build(), - Message.builder().name("Claudius").content("Alas, alas!").build(), - Message.builder().name("Hamlet").content("A man may fish with the worm that hath eat of a king, and eat \n" - + "of the fish that hath fed of that worm.").build(), - Message.builder().name("Claudius").content("What dost thou mean by this?").build(), - Message.builder().name("Hamlet").content("Nothing but to show you how a king may go a progress through \n" - + "the guts of a beggar.\n" - + "").build(), - Message.builder().name("Claudius").content("Where is Polonius?").build(), - Message.builder().name("Hamlet").content("In heaven. Send thither to see. If your messenger find him not \n" - + "there, seek him i' th' other place yourself. But indeed, if you\n" - + "find him not within this month, you shall nose him as you go up \n" - + "the stair, into the lobby.").build(), - Message.builder().name("Claudius").content("Go seek him there.").build(), - Message.builder().name("Hamlet").content("He will stay till you come.").build(), - Message.builder().name("Claudius").content("Hamlet, this deed, for thine especial safety,- \n" - + "Which we do tender as we dearly grieve \n" - + "For that which thou hast done,- must send thee hence \n" - + "With fiery quickness. Therefore prepare thyself. \n" - + "The bark is ready and the wind at help, \n" - + "Th' associates tend, and everything is bent \n" - + "For England.").build(), - Message.builder().name("Hamlet").content("For England?").build(), - Message.builder().name("Claudius").content("Ay, Hamlet.").build(), - Message.builder().name("Hamlet").content("Good.").build(), - Message.builder().name("Claudius").content("So is it, if thou knew'st our purposes.").build(), - Message.builder().name("Hamlet").content("I see a cherub that sees them. But come, for England! \n" - + "Farewell, dear mother.").build(), - Message.builder().name("Claudius").content("Thy loving father, Hamlet.").build(), - Message.builder().name("Hamlet").content("My mother! Father and mother is man and wife; man and wife is\n" - + "one flesh; and so, my mother. Come, for England!").build(), - Message.builder().name("Claudius").content("Follow him at foot; tempt him with speed aboard. \n" - + "Delay it not; I'll have him hence to-night. \n" - + "Away! for everything is seal'd and done\n" - + "That else leans on th' affair. Pray you make haste.").build(), - Message.builder().name("Claudius").content("And, England, if my love thou hold'st at aught,- \n" - + "As my great power thereof may give thee sense, \n" - + "Since yet thy cicatrice looks raw and red\n" - + "After the Danish sword, and thy free awe \n" - + "Pays homage to us,- thou mayst not coldly set \n" - + "Our sovereign process, which imports at full, \n" - + "By letters congruing to that effect, \n" - + "The present death of Hamlet. Do it, England; \n" - + "For like the hectic in my blood he rages, \n" - + "And thou must cure me. Till I know 'tis done, \n" - + "Howe'er my haps, my joys were ne'er begun. ").build() - )); - + public ChatAssistantLazyLoadingDemo() { + // Backing message list, served lazily by the data provider below. + List messages = createMessages(); + + // Create the assistant; the "small" class shrinks the message font (see demo CSS). ChatAssistant chatAssistant = new ChatAssistant<>(); chatAssistant.setClassName("small"); SvgIcon icon = new SvgIcon("chatbot.svg"); @@ -131,51 +58,143 @@ public ChatAssistantLazyLoadingDemo() { chatAssistant.setFabIcon(icon); chatAssistant.setWindowWidth("400px"); chatAssistant.setWindowHeight("400px"); + + // Feed messages through a lazy DataProvider, reporting the requested page as it loads. Span lazyLoadingData = new Span(); - DataProvider dataProvider = DataProvider.fromCallbacks(query->{ - lazyLoadingData.setText("Loading messages from: " + query.getOffset() + ", with limit: " + query.getLimit()); + DataProvider dataProvider = DataProvider.fromCallbacks(query -> { + lazyLoadingData.setText( + "Loading messages from: " + query.getOffset() + ", with limit: " + query.getLimit() + ); return messages.stream().skip(query.getOffset()).limit(query.getLimit()); - }, query->{ - return messages.size(); - }); + }, query -> messages.size()); chatAssistant.setDataProvider(dataProvider); - + + // Replace the default header with a custom title bar and minimize button. + Icon minimize = VaadinIcon.MINUS.create(); + minimize.addClickListener(ev -> chatAssistant.setOpened(!chatAssistant.isOpened())); + Span title = new Span("Customized Assistant Header"); + title.setWidthFull(); + HorizontalLayout headerBar = new HorizontalLayout(title, minimize); + headerBar.setWidthFull(); + chatAssistant.setHeaderComponent(headerBar); + + // Text area used to compose the assistant's answer. TextArea message = new TextArea(); message.setLabel("Enter a message from the assistant"); message.setSizeFull(); - message.addKeyPressListener(ev->{ + message.addKeyPressListener(ev -> { if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); } }); - message.addBlurListener(ev->chatAssistant.clearWhoIsTyping()); + message.addBlurListener(ev -> chatAssistant.clearWhoIsTyping()); + // With a custom DataProvider, append to the list and refresh instead of using sendMessage. Button chat = new Button("Chat"); chat.addClickListener(ev -> { - Message m = Message.builder().content(message.getValue()).messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").build(); - - messages.add(m); - dataProvider.refreshAll(); - chatAssistant.scrollToEnd(); - message.clear(); + messages.add(Message.builder() + .content(message.getValue()) + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build()); + dataProvider.refreshAll(); + chatAssistant.scrollToEnd(); + message.clear(); }); + + // Messages typed in the chat input are appended the same way. chatAssistant.setSubmitListener(ev -> { - Message userMessage = Message.builder().messageTime(LocalDateTime.now()) - .name("User").content(ev.getValue()).build(); - messages.add(userMessage); - dataProvider.refreshAll(); - chatAssistant.scrollToEnd(); + messages.add(Message.builder() + .messageTime(LocalDateTime.now()) + .name("User") + .content(ev.getValue()) + .build() + ); + dataProvider.refreshAll(); + chatAssistant.scrollToEnd(); }); - Icon minimize = VaadinIcon.MINUS.create(); - minimize.addClickListener(ev -> chatAssistant.setOpened(!chatAssistant.isOpened())); - Span title = new Span("Customized Assistant Header"); - title.setWidthFull(); - HorizontalLayout headerBar = new HorizontalLayout(title, minimize); - headerBar.setWidthFull(); - chatAssistant.setHeaderComponent(headerBar); + + chatAssistant.setOpened(true); add(message, lazyLoadingData, chat, chatAssistant); } - + + /** Sample conversation (an excerpt from Hamlet) used to populate the lazy data provider. */ + private static List createMessages() { + return new ArrayList<>(Arrays.asList( + Message.builder().name("Claudius").content("I have sent to seek him and to find the body.\n" + + "How dangerous is it that this man goes loose!\n" + + "Yet must not we put the strong law on him.\n" + + "He's loved of the distracted multitude,\n" + + "Who like not in their judgment, but their eyes.\n" + + "And where 'tis so, th' offender's scourge is weighed,\n" + + "But never the offense. To bear all smooth and even,\n" + + "This sudden sending him away must seem\n" + + "Deliberate pause. Diseases desperate grown\n" + + "By desperate appliance are relieved,\n" + + "Or not at all.").build(), + Message.builder().name("Rosencrantz").content("(Enter)").build(), + Message.builder().name("Claudius").content("How now, what hath befall'n?").build(), + Message.builder().name("Rosencrantz").content("Where the dead body is bestowed, my lord,\n" + + "We cannot get from him.").build(), + Message.builder().name("Claudius").content("But where is he?").build(), + Message.builder().name("Rosencrantz").content("Without, my lord; guarded, to know your pleasure.").build(), + Message.builder().name("Claudius").content("Bring him before us.").build(), + Message.builder().name("Rosencrantz").content("Ho, Guildenstern! Bring in my lord.").build(), + Message.builder().name("Claudius").content("Now, Hamlet, where's Polonius?").build(), + Message.builder().name("Hamlet").content("At supper.").build(), + Message.builder().name("Claudius").content("At supper? Where? ").build(), + Message.builder().name("Hamlet").content("Not where he eats, but where he is eaten. A certain \n" + + "convocation of politic worms are e'en at him. Your worm is your \n" + + "only emperor for diet. We fat all creatures else to fat us, and \n" + + "we fat ourselves for maggots. Your fat king and your lean beggar \n" + + "is but variable service- two dishes, but to one table. That's the \n" + + "end.").build(), + Message.builder().name("Claudius").content("Alas, alas!").build(), + Message.builder().name("Hamlet").content("A man may fish with the worm that hath eat of a king, and eat \n" + + "of the fish that hath fed of that worm.").build(), + Message.builder().name("Claudius").content("What dost thou mean by this?").build(), + Message.builder().name("Hamlet").content("Nothing but to show you how a king may go a progress through \n" + + "the guts of a beggar.\n" + + "").build(), + Message.builder().name("Claudius").content("Where is Polonius?").build(), + Message.builder().name("Hamlet").content("In heaven. Send thither to see. If your messenger find him not \n" + + "there, seek him i' th' other place yourself. But indeed, if you\n" + + "find him not within this month, you shall nose him as you go up \n" + + "the stair, into the lobby.").build(), + Message.builder().name("Claudius").content("Go seek him there.").build(), + Message.builder().name("Hamlet").content("He will stay till you come.").build(), + Message.builder().name("Claudius").content("Hamlet, this deed, for thine especial safety,- \n" + + "Which we do tender as we dearly grieve \n" + + "For that which thou hast done,- must send thee hence \n" + + "With fiery quickness. Therefore prepare thyself. \n" + + "The bark is ready and the wind at help, \n" + + "Th' associates tend, and everything is bent \n" + + "For England.").build(), + Message.builder().name("Hamlet").content("For England?").build(), + Message.builder().name("Claudius").content("Ay, Hamlet.").build(), + Message.builder().name("Hamlet").content("Good.").build(), + Message.builder().name("Claudius").content("So is it, if thou knew'st our purposes.").build(), + Message.builder().name("Hamlet").content("I see a cherub that sees them. But come, for England! \n" + + "Farewell, dear mother.").build(), + Message.builder().name("Claudius").content("Thy loving father, Hamlet.").build(), + Message.builder().name("Hamlet").content("My mother! Father and mother is man and wife; man and wife is\n" + + "one flesh; and so, my mother. Come, for England!").build(), + Message.builder().name("Claudius").content("Follow him at foot; tempt him with speed aboard. \n" + + "Delay it not; I'll have him hence to-night. \n" + + "Away! for everything is seal'd and done\n" + + "That else leans on th' affair. Pray you make haste.").build(), + Message.builder().name("Claudius").content("And, England, if my love thou hold'st at aught,- \n" + + "As my great power thereof may give thee sense, \n" + + "Since yet thy cicatrice looks raw and red\n" + + "After the Danish sword, and thy free awe \n" + + "Pays homage to us,- thou mayst not coldly set \n" + + "Our sovereign process, which imports at full, \n" + + "By letters congruing to that effect, \n" + + "The present death of Hamlet. Do it, England; \n" + + "For like the hectic in my blood he rages, \n" + + "And thou must cure me. Till I know 'tis done, \n" + + "Howe'er my haps, my joys were ne'er begun. ").build())); + } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java index 2a665b9..0411b8c 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java @@ -7,9 +7,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -34,39 +34,58 @@ @DemoSource(sourcePosition = SourcePosition.PRIMARY) @PageTitle("Markdown Demo") -@SuppressWarnings("serial") @Route(value = "chat-assistant/markdown-demo", layout = ChatAssistantDemoView.class) @CssImport("./styles/chat-assistant-styles-demo.css") public class ChatAssistantMarkdownDemo extends VerticalLayout { - + public ChatAssistantMarkdownDemo() { + // Pass true to the constructor to render message content as Markdown. ChatAssistant chatAssistant = new ChatAssistant<>(true); SvgIcon icon = new SvgIcon("chatbot.svg"); icon.setColor("var(--lumo-primary-contrast-color)"); chatAssistant.setFabIcon(icon); chatAssistant.setWindowWidth("400px"); chatAssistant.setWindowHeight("400px"); + + // Text area used to compose the assistant's answer, prefilled with a Markdown sample to send. TextArea message = new TextArea(); message.setLabel("Enter a message from the assistant (try using Markdown)"); message.setSizeFull(); - message.addKeyPressListener(ev->{ + message.setValue("# Heading\n\n" + + "Some **bold** and *italic* text, with `inline code`.\n\n" + + "- First item\n" + + "- Second item\n"); + message.addKeyPressListener(ev -> { if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); } }); - message.addBlurListener(ev->chatAssistant.clearWhoIsTyping()); + message.addBlurListener(ev -> chatAssistant.clearWhoIsTyping()); + // Send the composed Markdown as an assistant message. Button chat = new Button("Chat"); chat.addClickListener(ev -> { - Message m = Message.builder().content(message.getValue()).messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").build(); - - chatAssistant.sendMessage(m); + chatAssistant.sendMessage( + Message.builder() + .content(message.getValue()) + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build() + ); message.clear(); }); - chatAssistant.sendMessage(Message.builder().content("**Hello, I am here to assist you**") + + // Seed the conversation with a Markdown-formatted greeting and open the window. + chatAssistant.sendMessage( + Message.builder() + .content("**Hello, I am here to assist you**") .messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").build()); + .name("Assistant") + .avatar("chatbot.png") + .build() + ); + chatAssistant.setOpened(true); add(message, chat, chatAssistant); } From 80e04d015b3f48db0fcab0eb3e291c6528f7bee4 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:03 -0300 Subject: [PATCH 10/25] feat(demo): add FAB configuration and in-a-box demos A FAB configuration demo (custom icon, size and color variants, section titles, movable/resizable/indicator toggles with notifications) and an in-a-box demo with a container-anchored FAB positioned via setFabPosition. --- .../chatassistant/ChatAssistantBoxDemo.java | 87 +++++++++++++ .../ChatAssistantFabConfigDemo.java | 121 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java create mode 100644 src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java new file mode 100644 index 0000000..abd24fb --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java @@ -0,0 +1,87 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2025 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.chatassistant; + +import com.flowingcode.vaadin.addons.chatassistant.model.FabPosition; +import com.flowingcode.vaadin.addons.chatassistant.model.Message; +import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.flowingcode.vaadin.addons.demo.SourcePosition; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.dependency.CssImport; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.icon.SvgIcon; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import java.time.LocalDateTime; + +@DemoSource(sourcePosition = SourcePosition.PRIMARY) +@PageTitle("FAB In A Box Demo") +@Route(value = "chat-assistant/box-demo", layout = ChatAssistantDemoView.class) +@CssImport("./styles/chat-assistant-styles-demo.css") +public class ChatAssistantBoxDemo extends VerticalLayout { + + public ChatAssistantBoxDemo() { + SvgIcon icon = new SvgIcon("chatbot.svg"); + icon.setColor("var(--lumo-primary-contrast-color)"); + + // With fabAnchoredToViewport(false) the FAB is positioned relative to its container instead of + // the viewport, so it lives inside the box below rather than floating over the whole screen. + ChatAssistant chatAssistant = ChatAssistant.builder() + .fabIcon(icon) + .fabAnchoredToViewport(false) + .build(); + chatAssistant.setWindowWidth("400px"); + chatAssistant.setWindowHeight("400px"); + + // Seed the conversation with a greeting. + chatAssistant.sendMessage( + Message.builder() + .content("Use the buttons to move me around the box.") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build() + ); + + // Move the FAB to each corner of the box. + HorizontalLayout controls = new HorizontalLayout( + new Button("Top left", ev -> chatAssistant.setFabPosition(FabPosition.TOP_LEFT)), + new Button("Top right", ev -> chatAssistant.setFabPosition(FabPosition.TOP_RIGHT)), + new Button("Bottom left", ev -> chatAssistant.setFabPosition(FabPosition.BOTTOM_LEFT)), + new Button("Bottom right", ev -> chatAssistant.setFabPosition(FabPosition.BOTTOM_RIGHT)) + ); + controls.getStyle().set("flex-wrap", "wrap"); + + // A visible, relatively-positioned box that hosts the non-fixed FAB. + Span description = new Span("The FAB is not anchored to the viewport but positioned relative to this box. This behaviour disables dragging."); + Div box = new Div(chatAssistant); + box.getStyle() + .set("position", "relative") + .set("border", "2px dashed var(--lumo-contrast-30pct)") + .set("border-radius", "var(--lumo-border-radius-l)") + .setWidth("600px") + .setHeight("400px"); + + add(controls, description, box); + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java new file mode 100644 index 0000000..cb1aaea --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java @@ -0,0 +1,121 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2025 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.chatassistant; + +import com.flowingcode.vaadin.addons.chatassistant.model.Message; +import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.flowingcode.vaadin.addons.demo.SourcePosition; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.dependency.CssImport; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.icon.SvgIcon; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import java.time.LocalDateTime; + +@DemoSource(sourcePosition = SourcePosition.PRIMARY) +@PageTitle("FAB Configuration Demo") +@Route(value = "chat-assistant/fab-config-demo", layout = ChatAssistantDemoView.class) +@CssImport("./styles/chat-assistant-styles-demo.css") +public class ChatAssistantFabConfigDemo extends VerticalLayout { + + public ChatAssistantFabConfigDemo() { + SvgIcon icon = new SvgIcon("chatbot.svg"); + + // Build the assistant with a custom FAB icon via the builder. + ChatAssistant chatAssistant = ChatAssistant.builder().fabIcon(icon).build(); + chatAssistant.setWindowWidth("400px"); + chatAssistant.setWindowHeight("400px"); + + // Size variants resize the FAB (and its icon): SMALL (50px) and LARGE (72px); removing the active + // one restores the default (60px). Only one size is active at a time. + Button small = new Button("Small", + ev -> chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_SMALL) + ); + Button large = new Button("Large", + ev -> chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_LARGE) + ); + Button defaultSize = new Button("Default size", ev -> chatAssistant.removeFabThemeVariants(ButtonVariant.LUMO_SMALL, + ButtonVariant.LUMO_LARGE) + ); + + // Color variants are applied to the underlying button; the icon follows the button color. + Button success = new Button("Success", + ev -> chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_SUCCESS) + ); + Button error = new Button("Error", + ev -> chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_ERROR) + ); + Button contrast = new Button("Contrast", + ev -> chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_CONTRAST) + ); + Button clearColors = new Button("Clear colors", ev -> chatAssistant.removeFabThemeVariants( + ButtonVariant.LUMO_SUCCESS, ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_CONTRAST) + ); + + // Lock down or free up the FAB and the window, and toggle the resize direction hints. Each toggle + // shows a notification reporting the resulting state. + Button movable = new Button("Toggle movable", ev -> { + chatAssistant.setFabMovable(!chatAssistant.isFabMovable()); + Notification.show("FAB movable: " + chatAssistant.isFabMovable()); + }); + Button resizable = new Button("Toggle resizable", ev -> { + chatAssistant.setWindowResizable(!chatAssistant.isWindowResizable()); + Notification.show("Window resizable: " + chatAssistant.isWindowResizable()); + }); + Button indicators = new Button("Toggle resize hints", ev -> { + chatAssistant.setResizeIndicatorsVisible(!chatAssistant.isResizeIndicatorsVisible()); + Notification.show("Resize hints visible: " + chatAssistant.isResizeIndicatorsVisible()); + }); + + // Seed the conversation and open the window so the styling and resize hints are visible right away. + chatAssistant.sendMessage( + Message.builder() + .content("Use the buttons to restyle the FAB.") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build() + ); + chatAssistant.setOpened(true); + + add(section("Size variants", small, large, defaultSize), + section("Color variants", success, error, contrast, clearColors), + section("Behavior", movable, resizable, indicators), + chatAssistant); + } + + /** A titled group of buttons whose row wraps on narrow screens. */ + private static VerticalLayout section(String title, Button... buttons) { + Span heading = new Span(title); + + HorizontalLayout row = new HorizontalLayout(buttons); + row.getStyle().set("flex-wrap", "wrap"); + + VerticalLayout group = new VerticalLayout(heading, row); + group.setPadding(false); + group.setSpacing(false); + return group; + } +} From 1e3f20c71cd3ef0478db6c9eb4176b39836c1835 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:03 -0300 Subject: [PATCH 11/25] feat(demo): add modes and responsive demo Desktop/mobile modes with breakpoint auto-switching, manual setMode, a mode-changed listener with notification, FAB position reset, and a chat window screen-size threshold listener. --- .../chatassistant/ChatAssistantModeDemo.java | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java new file mode 100644 index 0000000..da79bca --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java @@ -0,0 +1,95 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2025 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.chatassistant; + +import com.flowingcode.vaadin.addons.chatassistant.model.ChatAssistantMode; +import com.flowingcode.vaadin.addons.chatassistant.model.Message; +import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.flowingcode.vaadin.addons.demo.SourcePosition; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.dependency.CssImport; +import com.vaadin.flow.component.icon.SvgIcon; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import java.time.LocalDateTime; + +@DemoSource(sourcePosition = SourcePosition.PRIMARY) +@PageTitle("Modes & Responsive Demo") +@Route(value = "chat-assistant/mode-demo", layout = ChatAssistantDemoView.class) +@CssImport("./styles/chat-assistant-styles-demo.css") +public class ChatAssistantModeDemo extends VerticalLayout { + + public ChatAssistantModeDemo() { + SvgIcon icon = new SvgIcon("chatbot.svg"); + icon.setColor("var(--lumo-primary-contrast-color)"); + + // Build the assistant with auto-switching enabled: setting a breakpoint makes it switch to + // mobile (full-screen dialog) below 768px and back to desktop (anchored popover) above it. + ChatAssistant chatAssistant = ChatAssistant.builder() + .fabIcon(icon) + .mobileBreakpoint(768) + .build(); + chatAssistant.setWindowWidth("400px"); + chatAssistant.setWindowHeight("400px"); + + // React to every mode change, whether automatic or manual. + chatAssistant.addModeChangedListener( + ev -> Notification.show("Switched to " + ev.getMode() + " mode") + ); + + // React to the chat window's own size crossing a 500px width threshold (independent of the + // viewport breakpoint above). Fires once on registration and then on every crossing. + chatAssistant.addScreenSizeListener(500, null, + ev -> Notification.show("Chat window is now " + ev.getDirection() + " 500px wide") + ); + + // Switch the mode manually (only sticks while auto-switching is disabled). + Button mobile = new Button("Set mobile", ev -> chatAssistant.setMode(ChatAssistantMode.MOBILE)); + Button desktop = new Button("Set desktop", ev -> chatAssistant.setMode(ChatAssistantMode.DESKTOP)); + + // Freeze/resume automatic switching on the configured breakpoint. + Button toggleSwitching = new Button("Toggle auto switching", ev -> { + chatAssistant.setMobileModeSwitchingEnabled(!chatAssistant.isMobileModeSwitchingEnabled()); + Notification.show("Auto switching: " + chatAssistant.isMobileModeSwitchingEnabled()); + }); + + // Move the FAB back to its configured corner after it has been dragged. + Button reset = new Button("Reset FAB position", ev -> chatAssistant.resetFabPosition()); + + HorizontalLayout controls = new HorizontalLayout(mobile, desktop, toggleSwitching, reset); + controls.getStyle().set("flex-wrap", "wrap"); + + // Seed the conversation with a greeting and open the window. + chatAssistant.sendMessage( + Message.builder() + .content("Resize the window to switch modes.") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build() + ); + chatAssistant.setOpened(true); + + add(controls, chatAssistant); + } +} From 7817ff0c9f64e1e9814393474a972b09eb8d1d7d Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:03 -0300 Subject: [PATCH 12/25] feat(demo): register the demo views in the tabbed view --- .../vaadin/addons/chatassistant/ChatAssistantDemoView.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemoView.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemoView.java index c4d151b..9e5dd8d 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemoView.java @@ -25,14 +25,19 @@ import com.vaadin.flow.router.ParentLayout; import com.vaadin.flow.router.Route; -@SuppressWarnings("serial") @ParentLayout(DemoLayout.class) @Route("chat-assistant") @GithubLink("https://github.com/FlowingCode/ChatAssistant") public class ChatAssistantDemoView extends TabbedDemo { public ChatAssistantDemoView() { + // Core usage and FAB/window configuration. addDemo(ChatAssistantDemo.class); + addDemo(ChatAssistantFabConfigDemo.class); + addDemo(ChatAssistantBoxDemo.class); + addDemo(ChatAssistantModeDemo.class); + + // Content and data-handling features. addDemo(ChatAssistantLazyLoadingDemo.class); addDemo(ChatAssistantMarkdownDemo.class); addDemo(ChatAssistantGenerativeDemo.class); From d5afe759b15d825134340aaf7a548eed07f85fec Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:22 -0300 Subject: [PATCH 13/25] test: parameterize ChatAssistant type in serialization test --- .../vaadin/addons/chatassistant/test/SerializationTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/test/SerializationTest.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/test/SerializationTest.java index 79d2489..a97d46f 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/test/SerializationTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/test/SerializationTest.java @@ -45,7 +45,7 @@ private void testSerializationOf(Object obj) throws IOException, ClassNotFoundEx @Test public void testSerialization() throws ClassNotFoundException, IOException { try { - ChatAssistant chatAssistant = new ChatAssistant(); + ChatAssistant chatAssistant = new ChatAssistant(); chatAssistant.sendMessage(Message.builder().build()); testSerializationOf(chatAssistant); } catch (Exception e) { From 091c8e66122461f80b1da7fdc372049d88f8d02f Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:22 -0300 Subject: [PATCH 14/25] refactor: drop redundant serial annotations --- .../flowingcode/vaadin/addons/chatassistant/model/Message.java | 1 - .../com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.java | 1 - src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java | 1 - .../vaadin/addons/chatassistant/CustomChatMessage.java | 1 - .../flowingcode/vaadin/addons/chatassistant/CustomMessage.java | 1 - .../com/flowingcode/vaadin/addons/chatassistant/DemoView.java | 1 - 6 files changed, 6 deletions(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/Message.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/Message.java index 5ffdafc..67e6408 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/Message.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/Message.java @@ -33,7 +33,6 @@ * * @author mmlopez */ -@SuppressWarnings("serial") @Getter @Setter @SuperBuilder diff --git a/src/test/java/com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.java b/src/test/java/com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.java index 4860552..8239715 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.java +++ b/src/test/java/com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.java @@ -24,7 +24,6 @@ import com.vaadin.flow.component.page.Push; import com.vaadin.flow.server.AppShellSettings; -@SuppressWarnings("serial") @Push public class AppShellConfiguratorImpl implements AppShellConfigurator { diff --git a/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java b/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java index e633078..4fdfb59 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java +++ b/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java @@ -22,7 +22,6 @@ import com.vaadin.flow.component.html.Div; import com.vaadin.flow.router.RouterLayout; -@SuppressWarnings("serial") public class DemoLayout extends Div implements RouterLayout { public DemoLayout() { diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomChatMessage.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomChatMessage.java index 47e2627..93a8220 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomChatMessage.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomChatMessage.java @@ -2,7 +2,6 @@ import com.vaadin.flow.component.html.Span; -@SuppressWarnings("serial") public class CustomChatMessage extends ChatMessage { private Span tagline = new Span(); diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomMessage.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomMessage.java index d121cce..f052dc6 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomMessage.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomMessage.java @@ -6,7 +6,6 @@ import lombok.Setter; import lombok.experimental.SuperBuilder; -@SuppressWarnings("serial") @EqualsAndHashCode(callSuper = true) @Getter @Setter diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/DemoView.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/DemoView.java index 622b93d..4dac2b2 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/DemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/DemoView.java @@ -25,7 +25,6 @@ import com.vaadin.flow.router.BeforeEnterObserver; import com.vaadin.flow.router.Route; -@SuppressWarnings("serial") @Route("") public class DemoView extends VerticalLayout implements BeforeEnterObserver { From 9dfd4fd1ce728a3b29a3b340561a220da10ff1ab Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:22 -0300 Subject: [PATCH 15/25] test: tidy integration test imports and assertions --- .../flowingcode/vaadin/addons/chatassistant/it/BasicIT.java | 1 - .../flowingcode/vaadin/addons/chatassistant/it/ViewIT.java | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/BasicIT.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/BasicIT.java index 11f6202..9d118de 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/BasicIT.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/BasicIT.java @@ -23,7 +23,6 @@ import com.flowingcode.vaadin.addons.chatassistant.it.po.ChatBubbleElement; import com.vaadin.flow.component.button.testbench.ButtonElement; import com.vaadin.flow.component.html.testbench.NativeButtonElement; -import com.vaadin.flow.component.html.testbench.ParagraphElement; import com.vaadin.flow.component.notification.testbench.NotificationElement; import com.vaadin.flow.component.textfield.testbench.TextAreaElement; import com.vaadin.testbench.TestBenchElement; diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/ViewIT.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/ViewIT.java index 836271c..7f6787b 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/ViewIT.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/ViewIT.java @@ -22,11 +22,11 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; -import static org.junit.Assert.assertThat; import com.vaadin.testbench.TestBenchElement; import org.hamcrest.Description; import org.hamcrest.Matcher; +import org.hamcrest.MatcherAssert; import org.hamcrest.TypeSafeDiagnosingMatcher; import org.junit.Test; @@ -59,6 +59,6 @@ protected boolean matchesSafely(TestBenchElement item, Description mismatchDescr @Test public void componentWorks() { TestBenchElement element = $("chat-bot").first(); - assertThat(element, hasBeenUpgradedToCustomElement); + MatcherAssert.assertThat(element, hasBeenUpgradedToCustomElement); } } From 9f53b69899128624fd5e7ac06ccd4b260bab7cdc Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:22 -0300 Subject: [PATCH 16/25] chore: ignore generated web-component.html --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index de849b2..96e6271 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ drivers /types.d.ts* /frontend/generated /frontend/index.html +/frontend/web-component.html +/src/main/frontend/web-component.html /src/main/frontend/generated /src/main/frontend/index.html vite.generated.ts From 46911d78f54383cb2d106bd67430f2ef1a23948e Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Tue, 30 Jun 2026 12:06:22 -0300 Subject: [PATCH 17/25] docs(readme): document FAB, window, and mode features Expand the feature list and replace the outdated getting-started example with current builder/setter-based tutorials covering messaging, FAB styling, window sizing, and responsive mobile mode. --- README.md | 104 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 83 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 9b0cdc9..a99ea5f 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,16 @@ Vaadin Add-on that displays a chat assistant floating window using [Material UI' ## Features -* Messages can be sent by the user or programmatically. -* Listen for new messages written by the user. -* Toggle the chat window on/off. +* Send messages from the user or programmatically, and listen for messages written by the user. +* Toggle the chat window open/closed, or open it as a full-screen dialog on mobile. +* Markdown rendering, lazy loading via a `DataProvider`, and streaming ("generative") answers. +* Customizable floating action button (FAB): icon, size, color (`ButtonVariant` theme variants), + corner position and margin, draggable or fixed, and viewport- or container-anchored placement. +* Resizable chat window with eight drag handles, optional resize direction indicators, and + configurable initial size with min/max bounds. +* Responsive desktop/mobile modes with optional breakpoint-based auto-switching, plus listeners for + mode changes and for the chat window crossing a size threshold. +* A fluent `ChatAssistant.builder()` to configure everything declaratively. ## Supported versions @@ -96,24 +103,79 @@ Chat Assistant Add-on is written by Flowing Code S.A. ## Getting started -Simple example showing the basic options: - - ChatAssistant chatAssistant = new ChatAssistant(); - TextArea message = new TextArea(); - message.setLabel("Enter a message from the assistant"); - message.setSizeFull(); - - Button chat = new Button("Chat"); - chat.addClickListener(ev->{ - Message m = new Message(message.getValue(),false,false,0,false,new Sender("Assistant","1","https://ui-avatars.com/api/?name=Bot")); - chatAssistant.sendMessage(m); - message.clear(); - }); - chatAssistant.sendMessage(new Message("Hello, I am here to assist you",false,false,0,false,new Sender("Assistant","1","https://ui-avatars.com/api/?name=Bot"))); - chatAssistant.toggle(); - chatAssistant.addChatSentListener(ev->{ - Notification.show(ev.getMessage()); - }); +`ChatAssistant` renders a floating action button (FAB) that opens a chat window. +Add it to any layout and send messages to it; it shows a FAB in the bottom-right corner by default. + +```java +ChatAssistant chatAssistant = new ChatAssistant<>(); +add(chatAssistant); + +// Send a message programmatically (e.g. from the assistant). +chatAssistant.sendMessage(Message.builder() + .name("Assistant") + .content("Hello, I am here to assist you") + .messageTime(LocalDateTime.now()) + .build()); + +// React to messages typed by the user. +chatAssistant.setSubmitListener(ev -> + chatAssistant.sendMessage(Message.builder() + .name("User") + .content(ev.getValue()) + .messageTime(LocalDateTime.now()) + .build())); + +// Open or close the window programmatically. +chatAssistant.setOpened(true); +``` + +### Configuring with the builder + +Use `ChatAssistant.builder()` to configure the FAB and window declaratively: + +```java +ChatAssistant chatAssistant = ChatAssistant.builder() + .fabIcon(new SvgIcon("icons/my-icon.svg")) // custom FAB icon (defaults to a chatbot icon) + .defaultFabPosition(FabPosition.BOTTOM_LEFT) + .fabMovable(true) // allow dragging the FAB + .resizable(true) // allow resizing the window + .markdownEnabled(true) // render message content as Markdown + .build(); +``` + +Everything in the builder also has a setter, so the FAB and window can be reconfigured at runtime. + +### Styling the FAB + +```java +chatAssistant.setFabPosition(FabPosition.TOP_RIGHT); // move it (and set the reset corner) +chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_LARGE); // grow the FAB (LUMO_SMALL/LUMO_LARGE) +chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_CONTRAST); // recolor it (color variants) +chatAssistant.setResizeIndicatorsVisible(true); // show resize-direction hints +``` + +### Sizing the window + +```java +chatAssistant.setWindowWidth("400px"); +chatAssistant.setWindowHeight("500px"); +chatAssistant.setWindowMinWidth(300); // bounds honored on open and while resizing +chatAssistant.setWindowMaxWidth(700); +``` + +### Responsive / mobile mode + +In `MOBILE` mode the chat opens as a full-screen dialog. Set a breakpoint to switch automatically +between desktop (anchored popover) and mobile as the viewport crosses it: + +```java +ChatAssistant chatAssistant = ChatAssistant.builder() + .mobileBreakpoint(768) // auto-switch to mobile below 768px (auto-switching is opt-in) + .build(); + +chatAssistant.addModeChangedListener(ev -> Notification.show("Now in " + ev.getMode() + " mode")); +chatAssistant.setMode(ChatAssistantMode.MOBILE); // or switch manually +``` ## Special configuration when using Spring From 1e0196712fac1daed41aaefc67b13a5543454979 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Wed, 1 Jul 2026 10:12:16 -0300 Subject: [PATCH 18/25] refactor: add classname to markdown selector --- .../vaadin/addons/chatassistant/ChatMessage.java | 2 ++ .../resources/frontend/styles/fc-chat-message-styles.css | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java index f583fa0..fec2a11 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java @@ -46,6 +46,7 @@ public class ChatMessage extends Component implements HasComp 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. @@ -70,6 +71,7 @@ public ChatMessage(T message, boolean markdownEnabled) { this.add(loader); if (markdownEnabled) { markdown = new Markdown(message.getContent()); + markdown.setClassName(DEFAULT_MARKDOWN_CLASS); this.add(markdown); } setMessage(message); 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..5fa4ad1 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 @@ -90,12 +90,12 @@ /* 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); } From 657f684901413c2db25653a7979423d38b192868 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Wed, 1 Jul 2026 11:45:29 -0300 Subject: [PATCH 19/25] 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. --- .../addons/chatassistant/ChatAssistant.java | 243 +++++++++++++----- .../model/ChatAssistantMode.java | 2 + .../chatassistant/model/FabPosition.java | 6 +- .../chatassistant/model/FabVariant.java | 77 ++++++ .../ChatAssistantFabConfigDemo.java | 36 +-- 5 files changed, 282 insertions(+), 82 deletions(-) create mode 100644 src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabVariant.java 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..828f153 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java @@ -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; @@ -72,6 +76,9 @@ /** * 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, so attaching a second instance to the same UI throws an {@link IllegalStateException}. * * @author mmlopez */ @@ -87,6 +94,9 @@ public class ChatAssistant extends Div { protected Component fabIconComponent = fabIcon; protected boolean resizable = DEFAULT_WINDOW_RESIZABLE; protected boolean fabMovable = DEFAULT_FAB_MOVABLE; + // The desktop movable preference, remembered so it can be restored when leaving mobile mode (which + // always forces the FAB non-movable). fabMovable itself always reflects the true current state. + protected boolean desktopFabMovablePreference = DEFAULT_FAB_MOVABLE; protected ChatAssistantMode mode = DEFAULT_MODE; protected int mobileBreakpoint = DEFAULT_MOBILE_BREAKPOINT; protected boolean mobileModeSwitchingEnabled = false; @@ -95,7 +105,7 @@ public class ChatAssistant extends Div { protected FabPosition fabPosition = DEFAULT_POSITION; protected int fabMargin = DEFAULT_FAB_MARGIN; protected int fabSize = DEFAULT_FAB_SIZE; - protected ButtonVariant activeSizeVariant = null; + protected FabVariant activeSizeVariant = null; protected String minWidth = DEFAULT_CONTENT_MIN_WIDTH + "px"; protected String minHeight = DEFAULT_CONTENT_MIN_HEIGHT + "px"; @@ -132,7 +142,7 @@ public class ChatAssistant extends Div { 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 String DEFAULT_UNREAD_BADGE_COLOR = "var(--lumo-warning-contrast-color)"; protected static final int DEFAULT_CONTENT_MIN_WIDTH = 150; protected static final int DEFAULT_CONTENT_MIN_HEIGHT = 150; @@ -143,6 +153,13 @@ public class ChatAssistant extends Div { 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"; + // 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. + protected static final String CSS_MIN_WIDTH = "--fc-min-width"; + protected static final String CSS_MIN_HEIGHT = "--fc-min-height"; + protected static final String CSS_MAX_WIDTH = "--fc-max-width"; + protected static final String CSS_MAX_HEIGHT = "--fc-max-height"; protected static final String DEFAULT_FAB_ICON_SRC = loadDefaultFabIconSrc(); protected final VirtualList content = new VirtualList<>(); @@ -291,8 +308,8 @@ private void setUI( .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"); + .set(CSS_MIN_WIDTH,DEFAULT_CONTENT_MIN_WIDTH + "px") + .set(CSS_MIN_HEIGHT,DEFAULT_CONTENT_MIN_HEIGHT + "px"); mobileChatWindow.setSizeFull(); mobileChatWindow.setModal(false); @@ -329,7 +346,7 @@ private void setUI( .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,12 +356,11 @@ 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"); @@ -437,7 +453,8 @@ private void setUI( } /** Parses the given margin value, falling back to {@value #DEFAULT_FAB_MARGIN} when null or invalid. */ - private int parseFabMargin(String fabMargin) { + // 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; } @@ -451,6 +468,15 @@ private int parseFabMargin(String fabMargin) { @Override protected void onAttach(AttachEvent attachEvent) { super.onAttach(attachEvent); + // Only one ChatAssistant is supported per UI; a second instance would register competing FAB and + // window surfaces. Fail fast, but allow this same instance to re-attach (detach/attach cycles). + UI ui = attachEvent.getUI(); + Object registered = ComponentUtil.getData(ui, UI_INSTANCE_KEY); + if (registered != null && registered != this) { + throw new IllegalStateException( + "Only one ChatAssistant is supported per UI, but another instance is already attached."); + } + ComponentUtil.setData(ui, UI_INSTANCE_KEY, this); addComponentRefreshedListener( "fc-chat-assistant-drag-listener", "window.fcChatAssistantMovement($0, $1, $2, $3, $4, $5);", @@ -470,6 +496,17 @@ protected void onAttach(AttachEvent attachEvent) { 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) { @@ -602,7 +639,7 @@ public void setFabIcon(Component icon, int size) { /** * 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. + * 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 */ @@ -644,44 +681,42 @@ private void applyIconSize(Component icon, int 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. + * 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. * * @param variants the variants to add + * @since 5.1.0 */ - public void addFabThemeVariants(ButtonVariant... variants) { - for (ButtonVariant variant : variants) { - if (isSizeVariant(variant)) { + public void addFabThemeVariants(FabVariant... variants) { + for (FabVariant variant : variants) { + if (variant.isSizeVariant()) { 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); + fab.addThemeVariants(variant.toButtonVariant()); } } } /** * 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. * * @param variants the variants to remove + * @since 5.1.0 */ - public void removeFabThemeVariants(ButtonVariant... variants) { - for (ButtonVariant variant : variants) { - if (isSizeVariant(variant)) { + public void removeFabThemeVariants(FabVariant... variants) { + for (FabVariant variant : variants) { + if (variant.isSizeVariant()) { if (variant == activeSizeVariant) { activeSizeVariant = null; setFabSize(DEFAULT_FAB_SIZE); } } else { - fab.removeThemeVariants(variant); + fab.removeThemeVariants(variant.toButtonVariant()); } } } @@ -756,20 +791,37 @@ public boolean isWindowResizable() { * currently be dragged given the window's position. * * @param visible whether the resize direction indicators are visible + * @since 5.1.0 */ 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. */ + /** + * 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). + * + * @param movable whether the FAB can be dragged + * @since 5.1.0 + */ public void setFabMovable(boolean movable) { this.fabMovable = movable; + // Remember the user's choice as the desktop preference, but not the forced value applied while in + // mobile mode. + if (!isMobile()) { + this.desktopFabMovablePreference = movable; + } if(movable) { fab.getElement().setAttribute("movable", true); } else { @@ -777,7 +829,13 @@ public void setFabMovable(boolean movable) { } } - /** Returns true if the FAB is movable, false otherwise. **/ + /** + * Returns whether the FAB is currently movable. This reflects the effective state: it is always + * {@code false} while in {@link ChatAssistantMode#MOBILE} mode, regardless of the desktop preference. + * + * @return {@code true} if the FAB is currently movable + * @since 5.1.0 + */ public boolean isFabMovable() { return fabMovable; } @@ -786,6 +844,8 @@ public boolean isFabMovable() { * 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; @@ -798,7 +858,11 @@ public void setFabAnchoredToViewport(boolean anchoredToViewport) { } } - /** 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; } @@ -808,6 +872,7 @@ public boolean isFabAnchoredToViewport() { * {@link #resetFabPosition()} is called. * * @param fabPosition the corner to move the FAB to, it cannot be null + * @since 5.1.0 */ public void setFabPosition(FabPosition fabPosition) { Objects.requireNonNull(fabPosition, "Position cannot be null"); @@ -815,12 +880,20 @@ 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);", @@ -831,10 +904,11 @@ public void resetFabPosition() { * Sets the chat window minimum width, the lower bound enforced while resizing. * * @param minWidth the minimum width as a CSS length (e.g. "150px") + * @since 5.1.0 */ 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(); } @@ -842,10 +916,11 @@ public void setWindowMinWidth(String minWidth) { * Sets the chat window minimum width, the lower bound enforced while resizing. * * @param minWidth the minimum width in px (e.g. 150) + * @since 5.1.0 */ 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,this.minWidth); applyWindowConstraints(); } @@ -853,10 +928,11 @@ public void setWindowMinWidth(int minWidth) { * Sets the chat window minimum height, the lower bound enforced while resizing. * * @param minHeight the minimum height in px (e.g. 150) + * @since 5.1.0 */ 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,this.minHeight); applyWindowConstraints(); } @@ -864,10 +940,11 @@ public void setWindowMinHeight(int minHeight) { * Sets the chat window minimum height, the lower bound enforced while resizing. * * @param minHeight the minimum height as a CSS length (e.g. "150px") + * @since 5.1.0 */ 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(); } @@ -875,9 +952,10 @@ public void setWindowMinHeight(String minHeight) { * Sets the chat window maximum width, the upper bound enforced while resizing. * * @param maxWidth the maximum width as a CSS length + * @since 5.1.0 */ public void setWindowMaxWidth(String maxWidth) { - this.overlay.getStyle().set("--fc-max-width", maxWidth); + this.overlay.getStyle().set(CSS_MAX_WIDTH,maxWidth); applyWindowConstraints(); } @@ -885,9 +963,10 @@ public void setWindowMaxWidth(String maxWidth) { * Sets the chat window maximum width, the upper bound enforced while resizing. * * @param maxWidth the maximum width in px (e.g. 150) + * @since 5.1.0 */ public void setWindowMaxWidth(int maxWidth) { - this.overlay.getStyle().set("--fc-max-width", String.valueOf(maxWidth) + "px"); + this.overlay.getStyle().set(CSS_MAX_WIDTH,String.valueOf(maxWidth) + "px"); applyWindowConstraints(); } @@ -895,9 +974,10 @@ public void setWindowMaxWidth(int maxWidth) { * Sets the chat window maximum height, the upper bound enforced while resizing. * * @param maxHeight the maximum height as a CSS length + * @since 5.1.0 */ public void setWindowMaxHeight(String maxHeight) { - this.overlay.getStyle().set("--fc-max-height", maxHeight); + this.overlay.getStyle().set(CSS_MAX_HEIGHT,maxHeight); applyWindowConstraints(); } @@ -905,9 +985,10 @@ public void setWindowMaxHeight(String maxHeight) { * Sets the chat window maximum height, the upper bound enforced while resizing. * * @param maxHeight the maximum height in px (e.g. 150) + * @since 5.1.0 */ public void setWindowMaxHeight(int maxHeight) { - this.overlay.getStyle().set("--fc-max-height", String.valueOf(maxHeight) + "px"); + this.overlay.getStyle().set(CSS_MAX_HEIGHT,String.valueOf(maxHeight) + "px"); applyWindowConstraints(); } @@ -915,6 +996,7 @@ public void setWindowMaxHeight(int maxHeight) { * Sets the chat window's initial height. Use absolute units (e.g. "400px"). * * @param height the height as an absolute CSS length + * @since 5.1.0 */ public void setWindowHeight(String height) { applyWindowSize("height", height); @@ -924,6 +1006,7 @@ public void setWindowHeight(String height) { * Sets the chat window's initial height. Use absolute units (e.g. 400). * * @param height the height in px (e.g. 400) + * @since 5.1.0 */ public void setWindowHeight(int height) { applyWindowSize("height", String.valueOf(height) + "px"); @@ -933,6 +1016,7 @@ public void setWindowHeight(int height) { * Sets the chat window's initial width. Use absolute units (e.g. "400px"). * * @param width the width as an absolute CSS length + * @since 5.1.0 */ public void setWindowWidth(String width) { applyWindowSize("width", width); @@ -942,6 +1026,7 @@ public void setWindowWidth(String width) { * Sets the chat window's initial width. Use absolute units (e.g. 400). * * @param width the width in px (e.g. 400) + * @since 5.1.0 */ public void setWindowWidth(int width) { applyWindowSize("width", String.valueOf(width) + "px"); @@ -1044,51 +1129,51 @@ protected void initializeChatWindow() { screenSizeListeners.keySet().forEach(this::applyScreenSizeListener); addComponentRefreshedListener( "fc-chat-assistant-resize-top-listener", - "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'top');", - resizerTop.getElement(), overlay, + "window.fcChatAssistantResize($0, $1, $2, $3, $4, $5, 'top');", + this.getElement(), 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, + "window.fcChatAssistantResize($0, $1, $2, $3, $4, $5, 'bottom-right');", + this.getElement(), 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, + "window.fcChatAssistantResize($0, $1, $2, $3, $4, $5, 'top-right');", + this.getElement(), 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, + "window.fcChatAssistantResize($0, $1, $2, $3, $4, $5, 'right');", + this.getElement(), 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, + "window.fcChatAssistantResize($0, $1, $2, $3, $4, $5, 'bottom');", + this.getElement(), 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, + "window.fcChatAssistantResize($0, $1, $2, $3, $4, $5, 'left');", + this.getElement(), 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, + "window.fcChatAssistantResize($0, $1, $2, $3, $4, $5, 'top-left');", + this.getElement(), 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, + "window.fcChatAssistantResize($0, $1, $2, $3, $4, $5, 'bottom-left');", + this.getElement(), resizerBottomLeft.getElement(), overlay, DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE ); } @@ -1290,6 +1375,7 @@ public void setAvatarProvider(SerializableSupplier avatarProvider) { * Returns the number of unread messages displayed in the chat assistant. * * @return the number of unread messages + * @since 5.1.0 */ public int getUnreadMessages() { return Math.max(unreadMessages, 0); @@ -1300,6 +1386,7 @@ public int getUnreadMessages() { * range; the badge is hidden when it is 0. * * @param unreadMessages the number of unread messages to set + * @since 5.1.0 */ public void setUnreadMessages(int unreadMessages) { this.unreadMessages = unreadMessages >= 0 ? Math.min(unreadMessages, 99) : 0; @@ -1317,6 +1404,7 @@ public void setUnreadMessages(int unreadMessages) { * * @param background the background color of the unread badge * @param color the text color of the unread badge + * @since 5.1.0 */ public void setUnreadBadgeColors(String background, String color) { if(background != null && !background.isBlank()) { @@ -1345,13 +1433,18 @@ public void setUnreadBadgeColors(String background, String color) { * full manual control, disable automatic switching first. * * @param mode the mode to switch to, it cannot be null + * @since 5.1.0 */ public void setMode(ChatAssistantMode mode) { Objects.requireNonNull(mode, "Mode cannot be null"); setMode(mode, false); } - /** Returns the current display mode. */ + /** + * Returns the current display mode. + * + * @since 5.1.0 + */ public ChatAssistantMode getMode() { return mode; } @@ -1377,10 +1470,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 +1484,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(); @@ -1408,12 +1501,17 @@ protected void setMode(ChatAssistantMode mode, boolean fromClient) { * * @param mobileMode {@code true} for {@link ChatAssistantMode#MOBILE}, {@code false} for * {@link ChatAssistantMode#DESKTOP} + * @since 5.1.0 */ 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(); } @@ -1424,12 +1522,17 @@ public boolean isMobileMode() { * * @param listener the listener to add; it receives the mode the component switched to * @return a registration for removing the listener + * @since 5.1.0 */ public Registration addModeChangedListener(ComponentEventListener listener) { return addListener(ModeChangedEvent.class, listener); } - /** Event fired when the chat assistant switches between mobile and desktop mode. */ + /** + * Event fired when the chat assistant switches between mobile and desktop mode. + * + * @since 5.1.0 + */ public static class ModeChangedEvent extends ComponentEvent> { private final ChatAssistantMode mode; @@ -1461,6 +1564,7 @@ public ChatAssistantMode getMode() { * @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 + * @since 5.1.0 */ public Registration addScreenSizeListener(Integer width, Integer height, ComponentEventListener listener) { @@ -1513,7 +1617,11 @@ private static final class ScreenSizeListenerEntry implements Serializable { } } - /** 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, @@ -1525,6 +1633,8 @@ 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. + * + * @since 5.1.0 */ public static class ScreenSizeEvent extends ComponentEvent> { @@ -1568,6 +1678,7 @@ public ScreenSizeDirection getDirection() { * Returns the maximum screen width, in pixels, below which mobile mode is activated automatically. * * @return the breakpoint in pixels + * @since 5.1.0 */ public int getMobileBreakpoint() { return mobileBreakpoint; @@ -1585,6 +1696,7 @@ public int getMobileBreakpoint() { * 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 */ public void setMobileModeSwitchingEnabled(boolean enabled) { if (this.mobileModeSwitchingEnabled == enabled) { @@ -1603,6 +1715,7 @@ public void setMobileModeSwitchingEnabled(boolean enabled) { * Returns whether automatic switching between mobile and desktop mode is enabled. * * @return {@code true} if automatic switching is enabled + * @since 5.1.0 */ public boolean isMobileModeSwitchingEnabled() { return mobileModeSwitchingEnabled; 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..099fc84 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 @@ -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..4b73e71 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 @@ -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..ac0c9fc --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabVariant.java @@ -0,0 +1,77 @@ +/*- + * #%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; + +import com.vaadin.flow.component.button.ButtonVariant; + +/** + * Theme variants supported by the chat assistant floating action button (FAB). This is a curated + * subset of the underlying {@link ButtonVariant}s, so the add-on controls exactly which variants it + * exposes and stays insulated from {@code ButtonVariant} changes across Vaadin versions. + *

+ * Variants fall into two groups: the size variants {@link #SMALL} and {@link #LARGE} resize + * the FAB (and its icon) to a predefined diameter and are mutually exclusive (only one is active at a + * time), while the remaining color variants are applied to the underlying button and + * accumulate. + * + * @since 5.1.0 + */ +public enum FabVariant { + + /** Renders the FAB at the small predefined diameter. Size variant. */ + SMALL(ButtonVariant.LUMO_SMALL, true), + /** Renders the FAB at the large predefined diameter. Size variant. */ + LARGE(ButtonVariant.LUMO_LARGE, true), + /** Applies the primary color to the FAB. */ + PRIMARY(ButtonVariant.LUMO_PRIMARY, false), + /** Applies the success color to the FAB. */ + SUCCESS(ButtonVariant.LUMO_SUCCESS, false), + /** Applies the error color to the FAB. */ + ERROR(ButtonVariant.LUMO_ERROR, false), + /** Applies the contrast color to the FAB. */ + CONTRAST(ButtonVariant.LUMO_CONTRAST, false); + + private final ButtonVariant buttonVariant; + private final boolean sizeVariant; + + FabVariant(ButtonVariant buttonVariant, boolean sizeVariant) { + this.buttonVariant = buttonVariant; + this.sizeVariant = sizeVariant; + } + + /** + * Returns the underlying {@link ButtonVariant} this FAB variant maps to. + * + * @return the mapped {@link ButtonVariant} + */ + public ButtonVariant toButtonVariant() { + return buttonVariant; + } + + /** + * Returns whether this is a size variant ({@link #SMALL}/{@link #LARGE}), which resizes the FAB + * rather than being delegated to the underlying button. + * + * @return {@code true} if this is a size variant + */ + public boolean isSizeVariant() { + return sizeVariant; + } +} 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..7fb4f5b 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java @@ -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; @@ -51,27 +51,31 @@ public ChatAssistantFabConfigDemo() { // 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) + ev -> chatAssistant.addFabThemeVariants(FabVariant.SMALL) ); Button large = new Button("Large", - ev -> chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_LARGE) + ev -> chatAssistant.addFabThemeVariants(FabVariant.LARGE) ); - Button defaultSize = new Button("Default size", ev -> chatAssistant.removeFabThemeVariants(ButtonVariant.LUMO_SMALL, - ButtonVariant.LUMO_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) - ); + // 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.CONTRAST); + chatAssistant.addFabThemeVariants(FabVariant.SUCCESS); + }); + Button error = new Button("Error", ev -> { + chatAssistant.removeFabThemeVariants(FabVariant.SUCCESS, FabVariant.CONTRAST); + chatAssistant.addFabThemeVariants(FabVariant.ERROR); + }); + Button contrast = new Button("Contrast", ev -> { + chatAssistant.removeFabThemeVariants(FabVariant.SUCCESS, FabVariant.ERROR); + chatAssistant.addFabThemeVariants(FabVariant.CONTRAST); + }); Button clearColors = new Button("Clear colors", ev -> chatAssistant.removeFabThemeVariants( - ButtonVariant.LUMO_SUCCESS, ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_CONTRAST) + FabVariant.SUCCESS, FabVariant.ERROR, FabVariant.CONTRAST) ); // Lock down or free up the FAB and the window, and toggle the resize direction hints. Each toggle From 75da6fb7e251fba9ab2b1ae5d21b77672afa953d Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Wed, 1 Jul 2026 11:45:36 -0300 Subject: [PATCH 20/25] WIP: fix frontend listener cleanup and stale overlay references --- .../frontend/fc-chat-assistant-movement.js | 7 +++- .../frontend/fc-chat-assistant-resize.js | 33 +++++++++++++++---- 2 files changed, 33 insertions(+), 7 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 f11eadf..2be6fbb 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 @@ -302,7 +302,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..f0b9cee 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 @@ -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}`; @@ -288,11 +288,16 @@ 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) { + if (!overlay || !overlay.isConnected) { + contentPart = null; // a new overlay means the old content part is stale too overlay = document.querySelector(`.${popoverTag}`)?.shadowRoot?.querySelector(overlayTag); if(!overlay) { overlay = [...document.getElementsByClassName(popoverTag)].find(p => p.tagName == overlayTag); @@ -301,14 +306,30 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw 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); + + // Mirror the movement module's cleanup: when the component is detached, drop this direction's window + // resize listener, disconnect its style observer, and cancel the pending overlay lookup. Without this + // these closures (capturing item/container/overlay) would leak on detach. The guard on the durable + // resizer Div prevents re-adding on reopen, so this only runs on a genuine disconnect. + const origDisconnectedCallback = root.disconnectedCallback?.bind(root); + root.disconnectedCallback = () => { + window.removeEventListener('resize', resizeHandler); + styleObserver?.disconnect(); + clearTimeout(fetchOverlayTimeout); + origDisconnectedCallback?.(); + }; 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'); From ab78c8c7f71655035f97dfb06085209e8e07f1b2 Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Wed, 1 Jul 2026 11:45:41 -0300 Subject: [PATCH 21/25] 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. --- .../chatassistant/ChatAssistantLogicTest.java | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java 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..339f2c1 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java @@ -0,0 +1,145 @@ +/*- + * #%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; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +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 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")); + } + + // 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 mapping ------------------------------------------------------- + + @Test + public void fabVariant_everyConstant_mapsToButtonVariant() { + for (FabVariant variant : FabVariant.values()) { + assertNotNull("Missing mapping for " + variant, variant.toButtonVariant()); + } + } + + @Test + public void fabVariant_sizeClassification_isCorrect() { + assertTrue(FabVariant.SMALL.isSizeVariant()); + assertTrue(FabVariant.LARGE.isSizeVariant()); + assertFalse(FabVariant.SUCCESS.isSizeVariant()); + assertFalse(FabVariant.ERROR.isSizeVariant()); + assertFalse(FabVariant.CONTRAST.isSizeVariant()); + assertFalse(FabVariant.PRIMARY.isSizeVariant()); + } +} From b602461eb1cf0c64a77fda4ed15038d08a44da0d Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Wed, 1 Jul 2026 11:51:36 -0300 Subject: [PATCH 22/25] 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. --- .../addons/chatassistant/ChatAssistant.java | 38 +++++++++++++++++-- .../ChatAssistantFabConfigDemo.java | 10 +++-- 2 files changed, 41 insertions(+), 7 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 828f153..3f270bc 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java @@ -197,6 +197,10 @@ public ChatAssistant(List messages, boolean markdownEnabled) { null, null, null, + null, + null, + null, + null, null ); } @@ -242,6 +246,10 @@ public ChatAssistant(boolean markdownEnabled) { * @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( @@ -256,7 +264,11 @@ public ChatAssistant( FabPosition defaultFabPosition, String defaultFabMargin, String minWidth, - String minHeight + String minHeight, + String width, + String height, + String maxWidth, + String maxHeight ) { if (messages != null) { this.messages.addAll(messages); @@ -271,7 +283,11 @@ public ChatAssistant( defaultFabPosition, defaultFabMargin, minWidth, - minHeight + minHeight, + width, + height, + maxWidth, + maxHeight ); this.initializeHeader(); this.initializeFooter(); @@ -293,7 +309,11 @@ private void setUI( FabPosition fabPosition, String fabMargin, String minWidth, - String minHeight + String minHeight, + String width, + String height, + String maxWidth, + String maxHeight ) { String fontSize = "var(--lumo-font-size-xs)"; getStyle().setZIndex(1000); @@ -435,6 +455,18 @@ 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 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 7fb4f5b..7776942 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java @@ -43,10 +43,12 @@ 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. From 38ba082f7ce085c8667b69c5feba7904ae39688c Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Wed, 1 Jul 2026 12:29:57 -0300 Subject: [PATCH 23/25] WIP: apply Google Java Style --- .../addons/chatassistant/ChatAssistant.java | 819 ++++++++++-------- .../addons/chatassistant/ChatMessage.java | 54 +- .../chatassistant/model/FabVariant.java | 10 +- .../addons/chatassistant/model/Message.java | 11 +- .../addons/AppShellConfiguratorImpl.java | 5 +- .../flowingcode/vaadin/addons/DemoLayout.java | 4 +- .../chatassistant/ChatAssistantBoxDemo.java | 26 +- .../chatassistant/ChatAssistantDemo.java | 133 +-- .../chatassistant/ChatAssistantDemoView.java | 4 +- .../ChatAssistantFabConfigDemo.java | 130 +-- .../ChatAssistantGenerativeDemo.java | 167 ++-- .../ChatAssistantLazyLoadingDemo.java | 273 +++--- .../chatassistant/ChatAssistantLogicTest.java | 13 +- .../ChatAssistantMarkdownDemo.java | 55 +- .../chatassistant/ChatAssistantModeDemo.java | 44 +- .../chatassistant/CustomChatMessage.java | 5 +- .../addons/chatassistant/CustomMessage.java | 3 +- .../vaadin/addons/chatassistant/DemoView.java | 4 +- .../chatassistant/it/AbstractViewTest.java | 4 +- .../addons/chatassistant/it/BasicIT.java | 6 +- .../addons/chatassistant/it/ViewIT.java | 4 +- .../it/po/ChatAssistantElement.java | 4 +- .../it/po/ChatBubbleElement.java | 4 +- .../chatassistant/test/SerializationTest.java | 4 +- 24 files changed, 995 insertions(+), 791 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 3f270bc..cb55348 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java @@ -59,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; @@ -72,13 +69,15 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import lombok.Builder; /** * 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, so attaching a second instance to the same UI throws an {@link IllegalStateException}. + * + *

Only one {@code ChatAssistant} is supported per {@link UI}: the FAB and chat window are + * UI-level surfaces, so attaching a second instance to the same UI throws an {@link + * IllegalStateException}. * * @author mmlopez */ @@ -89,12 +88,14 @@ 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 + // 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; - // The desktop movable preference, remembered so it can be restored when leaving mobile mode (which + // The desktop movable preference, remembered so it can be restored when leaving mobile mode + // (which // always forces the FAB non-movable). fabMovable itself always reflects the true current state. protected boolean desktopFabMovablePreference = DEFAULT_FAB_MOVABLE; protected ChatAssistantMode mode = DEFAULT_MODE; @@ -153,7 +154,8 @@ public class ChatAssistant extends Div { 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"; - // Key under which the active ChatAssistant is tracked on its UI to enforce a single instance per UI. + // 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. protected static final String CSS_MIN_WIDTH = "--fc-min-width"; @@ -178,37 +180,36 @@ public class ChatAssistant extends Div { /** * 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()}. + * + *

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, - null, - null, - null - ); + null, + null, + null, + null, + null, + null, + 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); @@ -216,8 +217,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 */ @@ -230,65 +231,68 @@ 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 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, - String width, - String height, - String maxWidth, - String maxHeight - ) { + 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, - width, - height, - maxWidth, - maxHeight - ); + fabIcon, + resizable, + fabMovable, + mobileBreakpoint, + fabAnchoredToViewport, + resizeIndicatorsVisible, + defaultFabPosition, + defaultFabMargin, + minWidth, + minHeight, + width, + height, + maxWidth, + maxHeight); this.initializeHeader(); this.initializeFooter(); this.initializeContent(markdownEnabled != null && markdownEnabled); @@ -313,8 +317,7 @@ private void setUI( String width, String height, String maxWidth, - String maxHeight - ) { + String maxHeight) { String fontSize = "var(--lumo-font-size-xs)"; getStyle().setZIndex(1000); @@ -322,14 +325,15 @@ private void setUI( // 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(CSS_MIN_WIDTH,DEFAULT_CONTENT_MIN_WIDTH + "px") - .set(CSS_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) + .set(CSS_MIN_WIDTH, DEFAULT_CONTENT_MIN_WIDTH + "px") + .set(CSS_MIN_HEIGHT, DEFAULT_CONTENT_MIN_HEIGHT + "px"); mobileChatWindow.setSizeFull(); mobileChatWindow.setModal(false); @@ -345,7 +349,8 @@ private void setUI( 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) @@ -356,7 +361,8 @@ 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) @@ -384,67 +390,79 @@ private void setUI( chatWindow.setTarget(fab); applyGenericResizerStyle(resizerTop, "top"); - resizerTop.getStyle() + resizerTop + .getStyle() .setTop("0") .setLeft("0") .setHeight(DEFAULT_RESIZER_SIZE + "px") .setWidth("100%"); applyGenericResizerStyle(resizerBottom, "bottom"); - resizerBottom.getStyle() + resizerBottom + .getStyle() .setBottom("0") .setLeft("0") .setHeight(DEFAULT_RESIZER_SIZE + "px") .setWidth("100%"); applyGenericResizerStyle(resizerTopRight, "top-right"); - resizerTopRight.getStyle() + resizerTopRight + .getStyle() .setRight("0") .setTop("0") .setHeight(DEFAULT_RESIZER_SIZE + "px") .setWidth(DEFAULT_RESIZER_SIZE + "px"); applyGenericResizerStyle(resizerBottomRight, "bottom-right"); - resizerBottomRight.getStyle() + resizerBottomRight + .getStyle() .setBottom("0") .setRight("0") .setHeight(DEFAULT_RESIZER_SIZE + "px") .setWidth(DEFAULT_RESIZER_SIZE + "px"); applyGenericResizerStyle(resizerRight, "right"); - resizerRight.getStyle() + resizerRight + .getStyle() .setTop("0") .setRight("0") .setHeight("100%") .setWidth(DEFAULT_RESIZER_SIZE + "px"); applyGenericResizerStyle(resizerLeft, "left"); - resizerLeft.getStyle() + resizerLeft + .getStyle() .setTop("0") .setLeft("0") .setHeight("100%") .setWidth(DEFAULT_RESIZER_SIZE + "px"); applyGenericResizerStyle(resizerBottomLeft, "bottom-left"); - resizerBottomLeft.getStyle() + resizerBottomLeft + .getStyle() .setBottom("0") .setLeft("0") .setHeight(DEFAULT_RESIZER_SIZE + "px") .setWidth(DEFAULT_RESIZER_SIZE + "px"); applyGenericResizerStyle(resizerTopLeft, "top-left"); - resizerTopLeft.getStyle() + 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 - ); + resizerTop, + resizerBottom, + resizerRight, + resizerTopRight, + resizerBottomRight, + resizerLeft, + resizerTopLeft, + resizerBottomLeft, + container); this.fabPosition = fabPosition != null ? fabPosition : DEFAULT_POSITION; this.fabMargin = parseFabMargin(fabMargin); @@ -469,8 +487,10 @@ private void setUI( } // 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). + // 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; @@ -478,13 +498,20 @@ private void setUI( 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); + setFabAnchoredToViewport( + fabAnchoredToViewport != null ? fabAnchoredToViewport : DEFAULT_FAB_ANCHORED_TO_VIEWPORT); + setResizeIndicatorsVisible( + resizeIndicatorsVisible != null + ? resizeIndicatorsVisible + : DEFAULT_RESIZE_INDICATORS_VISIBLE); add(chatWindow, fabWrapper, mobileChatWindow); } - /** Parses the given margin value, falling back to {@value #DEFAULT_FAB_MARGIN} when null or invalid. */ + /** + * Parses the given margin value, falling back to {@value #DEFAULT_FAB_MARGIN} when null or + * invalid. + */ // Package-private (not private) so unit tests in this package can exercise the parsing fallbacks. int parseFabMargin(String fabMargin) { if (fabMargin == null) { @@ -500,7 +527,8 @@ int parseFabMargin(String fabMargin) { @Override protected void onAttach(AttachEvent attachEvent) { super.onAttach(attachEvent); - // Only one ChatAssistant is supported per UI; a second instance would register competing FAB and + // Only one ChatAssistant is supported per UI; a second instance would register competing FAB + // and // window surfaces. Fail fast, but allow this same instance to re-attach (detach/attach cycles). UI ui = attachEvent.getUI(); Object registered = ComponentUtil.getData(ui, UI_INSTANCE_KEY); @@ -512,16 +540,19 @@ protected void onAttach(AttachEvent 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() + 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. @@ -530,7 +561,8 @@ protected void onAttach(AttachEvent attachEvent) { @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 + // 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) { @@ -547,7 +579,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); @@ -564,21 +599,23 @@ 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() + 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 + // 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"); @@ -590,13 +627,11 @@ 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)) { + } 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. @@ -605,23 +640,26 @@ 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. + * 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); @@ -629,7 +667,8 @@ protected static SvgIcon createDefaultFabIcon() { /** 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")) { + 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) { @@ -652,8 +691,8 @@ public void setFabIcon(Component icon) { } /** - * 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 @@ -670,8 +709,9 @@ public void setFabIcon(Component icon, int size) { } /** - * 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. + * 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 */ @@ -687,9 +727,7 @@ protected void setFabSize(int size) { .setWidth(size + "px") .setMaxHeight(size + "px") .setMaxWidth(size + "px"); - fabWrapper.getStyle() - .setHeight(size + "px") - .setWidth(size + "px"); + fabWrapper.getStyle().setHeight(size + "px").setWidth(size + "px"); if (fabIconComponent != null) { applyIconSize(fabIconComponent, getFabIconSize()); } @@ -701,23 +739,26 @@ private int getFabIconSize() { } /** - * 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"); + .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 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. + * 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. * * @param variants the variants to add * @since 5.1.0 @@ -755,10 +796,9 @@ public void removeFabThemeVariants(FabVariant... variants) { /** 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(); } } @@ -767,8 +807,7 @@ public void setOpened(boolean opened) { public void open() { if (isMobile()) { mobileChatWindow.open(); - } - else { + } else { chatWindow.open(); } } @@ -777,8 +816,7 @@ public void open() { public void close() { if (isMobile()) { mobileChatWindow.close(); - } - else { + } else { chatWindow.close(); } } @@ -796,7 +834,7 @@ protected boolean isMobile() { /** Sets whether the chat window is resizable. */ public void setWindowResizable(boolean resizable) { this.resizable = resizable; - if(resizable) { + if (resizable) { overlay.getElement().setAttribute("resizable", true); } else { overlay.getElement().removeAttribute("resizable"); @@ -811,15 +849,15 @@ public void setWindowResizable(boolean resizable) { setResizerClass(resizerBottomRight, "bottom-right", resizable); } - /** Returns true if the chat window is resizable, false otherwise. **/ + /** 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 + * 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 @@ -840,21 +878,22 @@ public boolean isResizeIndicatorsVisible() { } /** - * 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). + * 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). * * @param movable whether the FAB can be dragged * @since 5.1.0 */ public void setFabMovable(boolean movable) { this.fabMovable = movable; - // Remember the user's choice as the desktop preference, but not the forced value applied while in + // Remember the user's choice as the desktop preference, but not the forced value applied while + // in // mobile mode. if (!isMobile()) { this.desktopFabMovablePreference = movable; } - if(movable) { + if (movable) { fab.getElement().setAttribute("movable", true); } else { fab.getElement().removeAttribute("movable"); @@ -863,7 +902,8 @@ public void setFabMovable(boolean movable) { /** * Returns whether the FAB is currently movable. This reflects the effective state: it is always - * {@code false} while in {@link ChatAssistantMode#MOBILE} mode, regardless of the desktop preference. + * {@code false} while in {@link ChatAssistantMode#MOBILE} mode, regardless of the desktop + * preference. * * @return {@code true} if the FAB is currently movable * @since 5.1.0 @@ -873,19 +913,18 @@ public boolean isFabMovable() { } /** - * 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"); } } @@ -927,9 +966,12 @@ public FabPosition getFabPosition() { * @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()); } /** @@ -940,7 +982,7 @@ public void resetFabPosition() { */ public void setWindowMinWidth(String minWidth) { this.minWidth = minWidth; - this.overlay.getStyle().set(CSS_MIN_WIDTH,minWidth); + this.overlay.getStyle().set(CSS_MIN_WIDTH, minWidth); applyWindowConstraints(); } @@ -952,7 +994,7 @@ public void setWindowMinWidth(String minWidth) { */ public void setWindowMinWidth(int minWidth) { this.minWidth = String.valueOf(minWidth) + "px"; - this.overlay.getStyle().set(CSS_MIN_WIDTH,this.minWidth); + this.overlay.getStyle().set(CSS_MIN_WIDTH, this.minWidth); applyWindowConstraints(); } @@ -964,7 +1006,7 @@ public void setWindowMinWidth(int minWidth) { */ public void setWindowMinHeight(int minHeight) { this.minHeight = String.valueOf(minHeight) + "px"; - this.overlay.getStyle().set(CSS_MIN_HEIGHT,this.minHeight); + this.overlay.getStyle().set(CSS_MIN_HEIGHT, this.minHeight); applyWindowConstraints(); } @@ -976,7 +1018,7 @@ public void setWindowMinHeight(int minHeight) { */ public void setWindowMinHeight(String minHeight) { this.minHeight = minHeight; - this.overlay.getStyle().set(CSS_MIN_HEIGHT,minHeight); + this.overlay.getStyle().set(CSS_MIN_HEIGHT, minHeight); applyWindowConstraints(); } @@ -987,7 +1029,7 @@ public void setWindowMinHeight(String minHeight) { * @since 5.1.0 */ public void setWindowMaxWidth(String maxWidth) { - this.overlay.getStyle().set(CSS_MAX_WIDTH,maxWidth); + this.overlay.getStyle().set(CSS_MAX_WIDTH, maxWidth); applyWindowConstraints(); } @@ -998,7 +1040,7 @@ public void setWindowMaxWidth(String maxWidth) { * @since 5.1.0 */ public void setWindowMaxWidth(int maxWidth) { - this.overlay.getStyle().set(CSS_MAX_WIDTH,String.valueOf(maxWidth) + "px"); + this.overlay.getStyle().set(CSS_MAX_WIDTH, String.valueOf(maxWidth) + "px"); applyWindowConstraints(); } @@ -1009,7 +1051,7 @@ public void setWindowMaxWidth(int maxWidth) { * @since 5.1.0 */ public void setWindowMaxHeight(String maxHeight) { - this.overlay.getStyle().set(CSS_MAX_HEIGHT,maxHeight); + this.overlay.getStyle().set(CSS_MAX_HEIGHT, maxHeight); applyWindowConstraints(); } @@ -1020,7 +1062,7 @@ public void setWindowMaxHeight(String maxHeight) { * @since 5.1.0 */ public void setWindowMaxHeight(int maxHeight) { - this.overlay.getStyle().set(CSS_MAX_HEIGHT,String.valueOf(maxHeight) + "px"); + this.overlay.getStyle().set(CSS_MAX_HEIGHT, String.valueOf(maxHeight) + "px"); applyWindowConstraints(); } @@ -1033,7 +1075,7 @@ public void setWindowMaxHeight(int maxHeight) { public void setWindowHeight(String height) { applyWindowSize("height", height); } - + /** * Sets the chat window's initial height. Use absolute units (e.g. 400). * @@ -1053,7 +1095,7 @@ public void setWindowHeight(int height) { public void setWindowWidth(String width) { applyWindowSize("width", width); } - + /** * Sets the chat window's initial width. Use absolute units (e.g. 400). * @@ -1067,20 +1109,24 @@ public void setWindowWidth(int width) { /** 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 - ); + 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). + * 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); + this.getElement() + .executeJs("window.fcChatAssistantApplyConstraints($0, $1);", overlay, DEFAULT_POPOVER_TAG); } protected void initializeHeader() { @@ -1096,14 +1142,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); @@ -1119,97 +1173,128 @@ 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 + // 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, $5, 'top');", - this.getElement(), 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, $5, 'bottom-right');", - this.getElement(), 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, $5, 'top-right');", - this.getElement(), 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, $5, 'right');", - this.getElement(), 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, $5, 'bottom');", - this.getElement(), 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, $5, 'left');", - this.getElement(), 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, $5, 'top-left');", - this.getElement(), 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, $5, 'bottom-left');", - this.getElement(), 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 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, $5, 'top');", + this.getElement(), + 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, $5, 'bottom-right');", + this.getElement(), + 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, $5, 'top-right');", + this.getElement(), + 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, $5, 'right');", + this.getElement(), + 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, $5, 'bottom');", + this.getElement(), + 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, $5, 'left');", + this.getElement(), + 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, $5, 'top-left');", + this.getElement(), + 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, $5, 'bottom-left');", + this.getElement(), + resizerBottomLeft.getElement(), + overlay, + DEFAULT_POPOVER_TAG, + DEFAULT_RESIZER_SIZE, + DEFAULT_MAX_RESIZER_SIZE); + } + }); } public void setDataProvider(DataProvider dataProvider) { @@ -1217,7 +1302,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 */ @@ -1235,22 +1321,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); @@ -1350,29 +1435,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. + * + *

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. + * @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(); } @@ -1414,8 +1493,8 @@ 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 * @since 5.1.0 @@ -1423,46 +1502,44 @@ public int getUnreadMessages() { 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. * * @param background the background color of the unread badge * @param color the text color of the unread badge * @since 5.1.0 */ 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.isEmpty()) { 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. * * @param mode the mode to switch to, it cannot be null * @since 5.1.0 @@ -1482,8 +1559,8 @@ public ChatAssistantMode getMode() { } /** - * 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 @@ -1529,10 +1606,11 @@ 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} + * @param mobileMode {@code true} for {@link ChatAssistantMode#MOBILE}, {@code false} for {@link + * ChatAssistantMode#DESKTOP} * @since 5.1.0 */ public void setMobileMode(boolean mobileMode) { @@ -1549,8 +1627,8 @@ public boolean isMobileMode() { } /** - * 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. * * @param listener the listener to add; it receives the mode the component switched to * @return a registration for removing the listener @@ -1569,7 +1647,8 @@ public static class ModeChangedEvent extends ComponentEvent> { 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; } @@ -1581,16 +1660,17 @@ 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. * * @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 @@ -1598,8 +1678,8 @@ public ChatAssistantMode getMode() { * @return a registration for removing the listener * @since 5.1.0 */ - 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"); @@ -1616,23 +1696,29 @@ 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 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); + 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. */ @@ -1641,8 +1727,8 @@ 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; @@ -1662,9 +1748,9 @@ 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 */ @@ -1674,8 +1760,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; @@ -1707,7 +1797,8 @@ 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. * * @return the breakpoint in pixels * @since 5.1.0 @@ -1718,16 +1809,17 @@ 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. - * - * @param enabled {@code true} to switch automatically on viewport changes, {@code false} to freeze + * 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 */ public void setMobileModeSwitchingEnabled(boolean enabled) { @@ -1736,8 +1828,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()); } 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 fec2a11..9dc26be 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.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. @@ -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,33 +38,33 @@ @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); @@ -79,26 +78,28 @@ 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 */ private void updateMessage(T message) { @@ -107,34 +108,35 @@ private void updateMessage(T message) { 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()); + // 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()); } } } - + /** * 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/FabVariant.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabVariant.java index ac0c9fc..3299667 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabVariant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabVariant.java @@ -25,11 +25,11 @@ * Theme variants supported by the chat assistant floating action button (FAB). This is a curated * subset of the underlying {@link ButtonVariant}s, so the add-on controls exactly which variants it * exposes and stays insulated from {@code ButtonVariant} changes across Vaadin versions. - *

- * Variants fall into two groups: the size variants {@link #SMALL} and {@link #LARGE} resize - * the FAB (and its icon) to a predefined diameter and are mutually exclusive (only one is active at a - * time), while the remaining color variants are applied to the underlying button and - * accumulate. + * + *

Variants fall into two groups: the size variants {@link #SMALL} and {@link #LARGE} + * resize the FAB (and its icon) to a predefined diameter and are mutually exclusive (only one is + * active at a time), while the remaining color variants are applied to the underlying + * button and accumulate. * * @since 5.1.0 */ 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..595fb48 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 @@ -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. @@ -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/test/java/com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.java b/src/test/java/com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.java index 8239715..cd093b3 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.java +++ b/src/test/java/com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.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. @@ -33,5 +33,4 @@ public void configurePage(AppShellSettings settings) { DynamicTheme.LUMO.initialize(settings); } } - } diff --git a/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java b/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java index 4fdfb59..2d06190 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java +++ b/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.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. diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java index abd24fb..7d6f81c 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantBoxDemo.java @@ -46,10 +46,8 @@ public ChatAssistantBoxDemo() { // 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 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..0657143 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java @@ -49,82 +49,103 @@ 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); + 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(); - }); + UI currentUI = UI.getCurrent(); + new Timer() + .schedule( + new TimerTask() { + @Override + public void run() { + currentUI.access( + () -> { + delayedMessage.setLoading(false); + chatAssistant.updateMessage(delayedMessage); + }); + } + }, + 5000); + 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); 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..4871935 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemoView.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. 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 7776942..f4210fb 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java @@ -44,69 +44,89 @@ public ChatAssistantFabConfigDemo() { SvgIcon icon = new SvgIcon("chatbot.svg"); // 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(); + 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 + // 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) - ); + 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 accumulate, so replace the other colors before applying the chosen one; this keeps + // 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.CONTRAST); - chatAssistant.addFabThemeVariants(FabVariant.SUCCESS); - }); - Button error = new Button("Error", ev -> { - chatAssistant.removeFabThemeVariants(FabVariant.SUCCESS, FabVariant.CONTRAST); - chatAssistant.addFabThemeVariants(FabVariant.ERROR); - }); - Button contrast = new Button("Contrast", ev -> { - chatAssistant.removeFabThemeVariants(FabVariant.SUCCESS, FabVariant.ERROR); - chatAssistant.addFabThemeVariants(FabVariant.CONTRAST); - }); - Button clearColors = new Button("Clear colors", ev -> chatAssistant.removeFabThemeVariants( - FabVariant.SUCCESS, FabVariant.ERROR, FabVariant.CONTRAST) - ); + Button success = + new Button( + "Success", + ev -> { + chatAssistant.removeFabThemeVariants(FabVariant.ERROR, FabVariant.CONTRAST); + chatAssistant.addFabThemeVariants(FabVariant.SUCCESS); + }); + Button error = + new Button( + "Error", + ev -> { + chatAssistant.removeFabThemeVariants(FabVariant.SUCCESS, FabVariant.CONTRAST); + chatAssistant.addFabThemeVariants(FabVariant.ERROR); + }); + Button contrast = + new Button( + "Contrast", + ev -> { + chatAssistant.removeFabThemeVariants(FabVariant.SUCCESS, FabVariant.ERROR); + chatAssistant.addFabThemeVariants(FabVariant.CONTRAST); + }); + Button clearColors = + new Button( + "Clear colors", + ev -> + chatAssistant.removeFabThemeVariants( + FabVariant.SUCCESS, FabVariant.ERROR, FabVariant.CONTRAST)); - // Lock down or free up the FAB and the window, and toggle the resize direction hints. Each toggle + // 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()); - }); + 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 right + // away. 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); @@ -114,14 +134,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..087ce18 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java @@ -46,10 +46,11 @@ 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!"; + 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 +61,99 @@ 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)); + streamWords(answer) + .forEach( + word -> + currentUI.access( + () -> { + delayedMessage.setContent(delayedMessage.getContent() + word); + chatAssistant.updateMessage(delayedMessage); + })); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + 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); @@ -158,13 +167,15 @@ 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 + " "; - }); + 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 de0438e..7f1bf07 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java @@ -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 index 339f2c1..b0a02d1 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java @@ -60,7 +60,8 @@ public void parseFabMargin_withPxSuffixAndWhitespace_isParsed() { @Test public void parseFabMargin_invalid_returnsDefault() { - assertEquals(ChatAssistant.DEFAULT_FAB_MARGIN, newChatAssistant().parseFabMargin("not-a-number")); + assertEquals( + ChatAssistant.DEFAULT_FAB_MARGIN, newChatAssistant().parseFabMargin("not-a-number")); } // setUnreadMessages clamping ---------------------------------------------- @@ -100,22 +101,20 @@ public void setUnreadMessages_boundaries_areKept() { @Test public void addScreenSizeListener_nullListener_throws() { ChatAssistant ca = newChatAssistant(); - assertThrows(NullPointerException.class, - () -> ca.addScreenSizeListener(100, 100, null)); + 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 -> {})); + 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 -> {})); + assertThrows(IllegalArgumentException.class, () -> ca.addScreenSizeListener(0, null, ev -> {})); } @Test 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..5f64a0f 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java @@ -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..01f1001 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java @@ -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..1fabfbd 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomChatMessage.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomChatMessage.java @@ -3,7 +3,7 @@ import com.vaadin.flow.component.html.Span; public class CustomChatMessage extends ChatMessage { - + private Span tagline = new Span(); public CustomChatMessage(CustomMessage message) { @@ -13,7 +13,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..2169aea 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomMessage.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomMessage.java @@ -11,7 +11,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..324e12f 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/DemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/DemoView.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. 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..779bb98 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 @@ -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. 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..e1e9fdb 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 @@ -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. @@ -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..0ef9ecb 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 @@ -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. 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..20fea92 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 @@ -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. 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..b49010d 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 @@ -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. 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..898b2d9 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 @@ -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. From 2025dd3a6045e6a37b179701dce8690e7a2ca04c Mon Sep 17 00:00:00 2001 From: Ezequiel M Izaguirre Date: Wed, 1 Jul 2026 12:38:22 -0300 Subject: [PATCH 24/25] WIP: normalize copyright year range --- .../addons/chatassistant/ChatAssistant.java | 6 +++--- .../addons/chatassistant/ChatMessage.java | 6 +++--- .../model/ChatAssistantMode.java | 6 +++--- .../chatassistant/model/FabPosition.java | 6 +++--- .../chatassistant/model/FabVariant.java | 6 +++--- .../addons/chatassistant/model/Message.java | 6 +++--- .../META-INF/VAADIN/package.properties | 19 ++++++++++++++++++ .../frontend/fc-chat-assistant-movement.js | 4 ++-- .../frontend/fc-chat-assistant-resize.js | 4 ++-- .../styles/fc-chat-assistant-style.css | 4 ++-- .../META-INF/resources/icons/chatbot.svg | 20 +++++++++++++++++++ .../addons/AppShellConfiguratorImpl.java | 4 ++-- .../flowingcode/vaadin/addons/DemoLayout.java | 6 +++--- .../chatassistant/ChatAssistantBoxDemo.java | 6 +++--- .../chatassistant/ChatAssistantDemo.java | 6 +++--- .../chatassistant/ChatAssistantDemoView.java | 6 +++--- .../ChatAssistantFabConfigDemo.java | 6 +++--- .../ChatAssistantGenerativeDemo.java | 6 +++--- .../ChatAssistantLazyLoadingDemo.java | 6 +++--- .../chatassistant/ChatAssistantLogicTest.java | 6 +++--- .../ChatAssistantMarkdownDemo.java | 6 +++--- .../chatassistant/ChatAssistantModeDemo.java | 6 +++--- .../chatassistant/CustomChatMessage.java | 19 ++++++++++++++++++ .../addons/chatassistant/CustomMessage.java | 19 ++++++++++++++++++ .../vaadin/addons/chatassistant/DemoView.java | 6 +++--- .../chatassistant/it/AbstractViewTest.java | 6 +++--- .../addons/chatassistant/it/BasicIT.java | 6 +++--- .../addons/chatassistant/it/ViewIT.java | 6 +++--- .../it/po/ChatAssistantElement.java | 6 +++--- .../it/po/ChatBubbleElement.java | 6 +++--- .../chatassistant/test/SerializationTest.java | 6 +++--- .../styles/chat-assistant-styles-demo.css | 2 +- .../resources/META-INF/resources/chatbot.svg | 20 +++++++++++++++++++ 33 files changed, 175 insertions(+), 78 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 cb55348..8ed1fee 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. 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 9dc26be..b0a1984 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.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. 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 099fc84..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. 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 4b73e71..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. 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 index 3299667..e592d12 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabVariant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabVariant.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. 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 595fb48..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,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. 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 2be6fbb..5bbee69 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. 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 f0b9cee..3e5806d 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. 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..9f37d36 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. 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 @@ + + + + Date: Wed, 1 Jul 2026 15:50:12 -0300 Subject: [PATCH 25/25] 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. --- .../addons/chatassistant/ChatAssistant.java | 25 +++++++++-- .../chatassistant/model/FabVariant.java | 42 ++++++++++++++----- .../styles/fc-chat-assistant-style.css | 10 +++-- .../ChatAssistantFabConfigDemo.java | 8 ++-- .../chatassistant/ChatAssistantLogicTest.java | 16 ++++++- 5 files changed, 77 insertions(+), 24 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 8ed1fee..c3a3133 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java @@ -142,8 +142,17 @@ public class ChatAssistant extends Div { 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-contrast-color)"; + // 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. + protected static final String DEFAULT_UNREAD_BADGE_BACKGROUND = + "var(--lumo-warning-color, var(--aura-accent-yellow, #e07a00))"; + protected 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. + protected static final String BADGE_FONT_SIZE = + "var(--lumo-font-size-xs, var(--aura-font-size-s, 0.75rem))"; + protected static final String BADGE_PADDING = + "var(--lumo-space-xs, var(--vaadin-padding-xs, 0.25rem))"; protected static final int DEFAULT_CONTENT_MIN_WIDTH = 150; protected static final int DEFAULT_CONTENT_MIN_HEIGHT = 150; @@ -318,7 +327,7 @@ private void setUI( String height, String maxWidth, String maxHeight) { - String fontSize = "var(--lumo-font-size-xs)"; + String fontSize = BADGE_FONT_SIZE; getStyle().setZIndex(1000); // The overlay fills the popover content part (which Vaadin clamps to the viewport); resizing @@ -368,7 +377,7 @@ private void setUI( .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%") @@ -770,6 +779,11 @@ public void addFabThemeVariants(FabVariant... variants) { setFabSize(variant == FabVariant.LARGE ? DEFAULT_FAB_LARGE_SIZE : DEFAULT_FAB_SMALL_SIZE); } else { fab.addThemeVariants(variant.toButtonVariant()); + // Aura styles the accent colors via a CSS class rather than the theme attribute, so also add + // it when present; this makes the color render under both Lumo and Aura. + if (variant.getAuraClass() != null) { + fab.getElement().getClassList().add(variant.getAuraClass()); + } } } } @@ -790,6 +804,9 @@ public void removeFabThemeVariants(FabVariant... variants) { } } else { fab.removeThemeVariants(variant.toButtonVariant()); + if (variant.getAuraClass() != null) { + fab.getElement().getClassList().remove(variant.getAuraClass()); + } } } } 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 index e592d12..37b769a 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabVariant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabVariant.java @@ -31,29 +31,38 @@ * active at a time), while the remaining color variants are applied to the underlying * button and accumulate. * + *

Theme compatibility. The add-on is compatible with both the Lumo and Aura themes of + * Vaadin 25. {@link #PRIMARY} uses the {@code primary} token, which both themes style. Aura, + * however, styles the accent colors through CSS classes rather than the {@code theme} attribute, so + * {@link #SUCCESS} and {@link #ERROR} additionally carry an Aura class (see {@link #getAuraClass()}) + * to render correctly under Aura as well as Lumo. {@link #LUMO_CONTRAST} has no Aura equivalent and is + * effective only under Lumo. + * * @since 5.1.0 */ public enum FabVariant { /** Renders the FAB at the small predefined diameter. Size variant. */ - SMALL(ButtonVariant.LUMO_SMALL, true), + SMALL(ButtonVariant.LUMO_SMALL, true, null), /** Renders the FAB at the large predefined diameter. Size variant. */ - LARGE(ButtonVariant.LUMO_LARGE, true), - /** Applies the primary color to the FAB. */ - PRIMARY(ButtonVariant.LUMO_PRIMARY, false), - /** Applies the success color to the FAB. */ - SUCCESS(ButtonVariant.LUMO_SUCCESS, false), - /** Applies the error color to the FAB. */ - ERROR(ButtonVariant.LUMO_ERROR, false), - /** Applies the contrast color to the FAB. */ - CONTRAST(ButtonVariant.LUMO_CONTRAST, false); + LARGE(ButtonVariant.LUMO_LARGE, true, null), + /** Applies the primary color to the FAB. Cross-theme (Lumo and Aura). */ + PRIMARY(ButtonVariant.LUMO_PRIMARY, false, null), + /** Applies the success (green) color to the FAB. Cross-theme (Lumo and Aura). */ + SUCCESS(ButtonVariant.LUMO_SUCCESS, false, "aura-accent-green"), + /** Applies the error (red) color to the FAB. Cross-theme (Lumo and Aura). */ + 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) { + FabVariant(ButtonVariant buttonVariant, boolean sizeVariant, String auraClass) { this.buttonVariant = buttonVariant; this.sizeVariant = sizeVariant; + this.auraClass = auraClass; } /** @@ -74,4 +83,15 @@ public ButtonVariant toButtonVariant() { public boolean isSizeVariant() { return sizeVariant; } + + /** + * Returns the Aura CSS class that must be applied alongside the {@link #toButtonVariant() Lumo + * theme variant} for this variant to render under the Aura theme, or {@code null} when no Aura + * class is needed (the variant is either cross-theme via its {@code theme} token, or Lumo-only). + * + * @return the Aura CSS class name, or {@code null} + */ + public String getAuraClass() { + return auraClass; + } } 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 9f37d36..e16025c 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 @@ -22,14 +22,15 @@ .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 { @@ -42,7 +43,8 @@ } 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 @@ -112,7 +114,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/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java index cbef8f5..80e78dd 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java @@ -64,14 +64,14 @@ public ChatAssistantFabConfigDemo() { new Button( "Success", ev -> { - chatAssistant.removeFabThemeVariants(FabVariant.ERROR, FabVariant.CONTRAST); + chatAssistant.removeFabThemeVariants(FabVariant.ERROR, FabVariant.LUMO_CONTRAST); chatAssistant.addFabThemeVariants(FabVariant.SUCCESS); }); Button error = new Button( "Error", ev -> { - chatAssistant.removeFabThemeVariants(FabVariant.SUCCESS, FabVariant.CONTRAST); + chatAssistant.removeFabThemeVariants(FabVariant.SUCCESS, FabVariant.LUMO_CONTRAST); chatAssistant.addFabThemeVariants(FabVariant.ERROR); }); Button contrast = @@ -79,14 +79,14 @@ public ChatAssistantFabConfigDemo() { "Contrast", ev -> { chatAssistant.removeFabThemeVariants(FabVariant.SUCCESS, FabVariant.ERROR); - chatAssistant.addFabThemeVariants(FabVariant.CONTRAST); + chatAssistant.addFabThemeVariants(FabVariant.LUMO_CONTRAST); }); Button clearColors = new Button( "Clear colors", ev -> chatAssistant.removeFabThemeVariants( - FabVariant.SUCCESS, FabVariant.ERROR, FabVariant.CONTRAST)); + 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 diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java index 8692008..5b2420d 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java @@ -22,6 +22,7 @@ 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; @@ -138,7 +139,20 @@ public void fabVariant_sizeClassification_isCorrect() { assertTrue(FabVariant.LARGE.isSizeVariant()); assertFalse(FabVariant.SUCCESS.isSizeVariant()); assertFalse(FabVariant.ERROR.isSizeVariant()); - assertFalse(FabVariant.CONTRAST.isSizeVariant()); + assertFalse(FabVariant.LUMO_CONTRAST.isSizeVariant()); assertFalse(FabVariant.PRIMARY.isSizeVariant()); } + + @Test + public void fabVariant_auraAccentClasses_areExposedForSuccessAndError() { + // Aura styles the accent colors via CSS class, so success/error must carry an Aura class. + assertEquals("aura-accent-green", FabVariant.SUCCESS.getAuraClass()); + assertEquals("aura-accent-red", FabVariant.ERROR.getAuraClass()); + // Cross-theme (PRIMARY) and Lumo-only (CONTRAST) variants need no Aura class. + assertNull(FabVariant.PRIMARY.getAuraClass()); + assertNull(FabVariant.LUMO_CONTRAST.getAuraClass()); + // Size variants don't apply a button theme at all. + assertNull(FabVariant.SMALL.getAuraClass()); + assertNull(FabVariant.LARGE.getAuraClass()); + } }