Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.messages.MessageInput;
import com.vaadin.flow.component.messages.MessageInput.SubmitEvent;
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;
Expand All @@ -44,6 +45,7 @@
import com.vaadin.flow.data.renderer.Renderer;
import com.vaadin.flow.function.SerializableSupplier;
import com.vaadin.flow.shared.Registration;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
Expand All @@ -62,6 +64,7 @@
@NpmPackage(value = "@emotion/react", version = "11.14.0")
@NpmPackage(value = "@emotion/styled", version = "11.14.0")
@JsModule("./react/animated-fab.tsx")
@JsModule("./fcChatAssistantConnector.js")
@Tag("animated-fab")
@CssImport("./styles/chat-assistant-styles.css")
public class ChatAssistant<T extends Message> extends ReactAdapterComponent implements ClickNotifier<ChatAssistant<T>> {
Expand Down Expand Up @@ -126,8 +129,9 @@ private void initializeHeader() {
@SuppressWarnings("unchecked")
private void initializeFooter() {
messageInput = new MessageInput();
messageInput.setSizeFull();
messageInput.getStyle().set("padding", PADDING_SMALL);
messageInput.setWidthFull();
messageInput.setMaxHeight("80px");
messageInput.getStyle().set("padding", "0");
defaultSubmitListenerRegistration = messageInput.addSubmitListener(se -> {
sendMessage((T) Message.builder().messageTime(LocalDateTime.now())
.name("User").content(se.getValue()).build());
Expand All @@ -136,7 +140,7 @@ private void initializeFooter() {
whoIsTyping.setClassName("chat-assistant-who-is-typing");
whoIsTyping.setVisible(false);
VerticalLayout footer = new VerticalLayout(whoIsTyping, messageInput);
footer.setSizeFull();
footer.setWidthFull();
footer.setSpacing(false);
footer.setMargin(false);
footer.setPadding(false);
Expand All @@ -151,14 +155,15 @@ private void initializeContent(boolean markdownEnabled) {
return component;
}));
content.setItems(messages);
content.setMinHeight("400px");
content.setMinWidth("400px");
content.setSizeFull();
container = new VerticalLayout(headerComponent, content, footerContainer);
container.setClassName("chat-assistant-container-vertical-layout");
container.setPadding(false);
container.setMargin(false);
container.setSpacing(false);
container.setSizeFull();
container.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN);
container.setFlexGrow(1, content);
}

private void initializeChatWindow() {
Expand All @@ -169,9 +174,10 @@ private void initializeChatWindow() {
chatWindow.add(resizableVL);
chatWindow.setOpenOnClick(false);
chatWindow.setCloseOnOutsideClick(false);
chatWindow.addOpenedChangeListener(ev->{
minimized = !ev.isOpened();
});
chatWindow.addOpenedChangeListener(ev -> minimized = !ev.isOpened());
chatWindow.addAttachListener(e -> e.getUI().getPage()
.executeJs("window.Vaadin.Flow.fcChatAssistantConnector.observePopoverResize($0)", chatWindow.getElement()));

this.getElement().addEventListener("avatar-clicked", ev ->{
if (this.minimized) {
chatWindow.open();
Expand Down Expand Up @@ -404,5 +410,5 @@ public int getUnreadMessages() {
public void setUnreadMessages(int unreadMessages) {
setState("unreadMessages",unreadMessages);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,19 @@
*/
vaadin-vertical-layout.chat-assistant-resizable-vertical-layout {
transform: rotate(180deg); /* This rotation, along with the one below, is a "double rotation trick." */
margin: -0.125em -0.375em 0;
margin: 0;
padding: 10px;
overflow: hidden;
width: 439px;
resize: both;
min-height: min-content;
min-width: min-content;
min-height: 30vh;
min-width: 310px;
width: var(--fc-chat-assistant-popover-width, 400px);
height: var(--fc-chat-assistant-popover-height, 400px);
}

vaadin-vertical-layout.chat-assistant-container-vertical-layout {
min-height: min-content;
flex-grow: 1;
gap: var(--lumo-space-s);
transform: rotate(180deg); /* This second rotation completes the "double rotation trick."
Together, these two rotations position the resize handle in the upper-left corner.
This new position is more suitable for resizing the chat window because the chat bubble
Expand All @@ -37,4 +40,36 @@ vaadin-vertical-layout.chat-assistant-container-vertical-layout {

.MuiBadge-badge {
z-index: 2000 !important;
}

vaadin-popover-overlay::part(content){
padding: 0;
}

vaadin-message::part(message) {
word-break: break-word;
}

vaadin-popover-overlay::part(overlay) {
max-width: 100vw; /* Prevent width beyond viewport */
max-height: 100vh; /* Prevent height beyond viewport */
}

/* Mobile breakpoint */
@media (max-width: 768px) {
vaadin-popover-overlay::part(overlay) {
width: 100%;
height: 100%;
}

vaadin-popover-overlay::part(content) {
width: 100%;
height: 100%;
}

vaadin-vertical-layout.chat-assistant-resizable-vertical-layout {
resize: none;
height: 100%;
width: 100%;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/*-
* #%L
* Chat Assistant Add-on
* %%
* Copyright (C) 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%
*/
(function () {
window.Vaadin.Flow.fcChatAssistantConnector = {
observePopoverResize: (popover) => {
// Skip the following logic on mobile devices by checking viewport width.
if (window.innerWidth <= 768) {
return;
}

if (popover.$connector) {
return;
}

popover.$connector = {};

// Find the resizable container inside the popover
const resizableContainer = popover.querySelector('.chat-assistant-resizable-vertical-layout');
if (!resizableContainer) return;

popover.addEventListener('opened-changed', e => {
if (e.detail.value) {
const popoverOverlay = resizableContainer.parentElement;
const overlay = popoverOverlay.shadowRoot?.querySelector('[part="overlay"]');
// Track overlay position changes and keep container inside viewport
trackOverlayPosition(overlay, resizableContainer, () => clampToViewport(resizableContainer));
}
});

// On drag/resize start (mouse), reset size restrictions so user can freely resize
resizableContainer.addEventListener("mousedown", e => {
resizableContainer.style.maxHeight = '';
resizableContainer.style.maxWidth = '';
});
// On drag/resize start (touch), reset size restrictions so user can freely resize
resizableContainer.addEventListener("touchstart", e => {
resizableContainer.style.maxHeight = '';
resizableContainer.style.maxWidth = '';
});

// Debounce calls to avoid excessive recalculations on rapid resize
const debouncedClamp = debounce(() => clampToViewport(resizableContainer));

new ResizeObserver(() => {
const popoverOverlay = resizableContainer.parentElement;
const overlay = popoverOverlay.shadowRoot?.querySelector('[part="overlay"]');
if (!overlay) return;

debouncedClamp();
}).observe(resizableContainer);


function debounce(callback) {
let rafId;
return () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(callback);
};
}

/**
* Restricts the size and position of a resizable container so that it remains fully visible
* within the browser's viewport, applying a small padding to keep it from touching the edges.
*
* This function calculates how much space is available on each side of the container
* (top, bottom, left, right) relative to the viewport. If the container would overflow
* on a given side, it adjusts `maxWidth`/`maxHeight` and aligns it to the opposite side
* with a fixed padding.
*
* - If there isn't enough space on the right, it clamps width and aligns to the left.
* - If there isn't enough space on the left, it clamps width and aligns to the right.
* - If there isn't enough space at the bottom, it clamps height and aligns to the top.
* - If there isn't enough space at the top, it clamps height and aligns to the bottom.
*
* @param {HTMLElement} resizableContainer - The element whose size and position should be clamped to the viewport.
*/
function clampToViewport(resizableContainer) {
const boundingClientRect = resizableContainer.getBoundingClientRect();

const containerWidthRight = boundingClientRect.width + (window.innerWidth - boundingClientRect.right);
const containerWidthLeft = boundingClientRect.left + boundingClientRect.width;
const containerHeightBottom = boundingClientRect.height + (window.innerHeight - boundingClientRect.bottom);
const containerHeightTop = boundingClientRect.top + boundingClientRect.height;

const padding = 5;
const paddingPx = padding + "px";

if (containerWidthRight >= window.innerWidth) {
resizableContainer.style.maxWidth = (boundingClientRect.right - padding) + "px";
resizableContainer.style.left = paddingPx;
} else if (containerWidthLeft >= window.innerWidth) {
resizableContainer.style.maxWidth = (window.innerWidth - boundingClientRect.left - padding) + "px";
resizableContainer.style.right = paddingPx;
}

if (containerHeightBottom >= window.innerHeight) {
resizableContainer.style.maxHeight = (boundingClientRect.bottom - padding) + "px";
resizableContainer.style.top = paddingPx;
} else if (containerHeightTop >= window.innerHeight) {
resizableContainer.style.maxHeight = (window.innerHeight - boundingClientRect.top - padding) + "px";
resizableContainer.style.bottom = paddingPx;
}
}

Comment on lines +94 to +121
Copy link

@coderabbitai coderabbitai bot Aug 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Clear opposing anchors (left/right, top/bottom) to avoid conflicting constraints

Keeping both left and right (or top and bottom) set across different clamping branches can create unintended fixed sizing and constrain user resizing. Maintain the “don’t reset maxWidth/maxHeight to avoid flicker” decision (per team learning), but do clear the opposite position anchor to prevent conflicts.

                 if (containerWidthRight >= window.innerWidth) {
                     resizableContainer.style.maxWidth = (boundingClientRect.right - padding) + "px";
                     resizableContainer.style.left = paddingPx;
+                    resizableContainer.style.right = '';
                 } else if (containerWidthLeft >= window.innerWidth) {
                     resizableContainer.style.maxWidth = (window.innerWidth - boundingClientRect.left - padding) + "px";
                     resizableContainer.style.right = paddingPx;
+                    resizableContainer.style.left = '';
                 }
 
                 if (containerHeightBottom >= window.innerHeight) {
                     resizableContainer.style.maxHeight = (boundingClientRect.bottom - padding) + "px";
                     resizableContainer.style.top = paddingPx;
+                    resizableContainer.style.bottom = '';
                 } else if (containerHeightTop >= window.innerHeight) {
                     resizableContainer.style.maxHeight = (window.innerHeight - boundingClientRect.top - padding) + "px";
                     resizableContainer.style.bottom = paddingPx;
+                    resizableContainer.style.top = '';
                 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function clampToViewport(resizableContainer) {
const boundingClientRect = resizableContainer.getBoundingClientRect();
const containerWidthRight = boundingClientRect.width + (window.innerWidth - boundingClientRect.right);
const containerWidthLeft = boundingClientRect.left + boundingClientRect.width;
const containerHeightBottom = boundingClientRect.height + (window.innerHeight - boundingClientRect.bottom);
const containerHeightTop = boundingClientRect.top + boundingClientRect.height;
const padding = 5;
const paddingPx = padding + "px";
if (containerWidthRight >= window.innerWidth) {
resizableContainer.style.maxWidth = (boundingClientRect.right - padding) + "px";
resizableContainer.style.left = paddingPx;
} else if (containerWidthLeft >= window.innerWidth) {
resizableContainer.style.maxWidth = (window.innerWidth - boundingClientRect.left - padding) + "px";
resizableContainer.style.right = paddingPx;
}
if (containerHeightBottom >= window.innerHeight) {
resizableContainer.style.maxHeight = (boundingClientRect.bottom - padding) + "px";
resizableContainer.style.top = paddingPx;
} else if (containerHeightTop >= window.innerHeight) {
resizableContainer.style.maxHeight = (window.innerHeight - boundingClientRect.top - padding) + "px";
resizableContainer.style.bottom = paddingPx;
}
}
function clampToViewport(resizableContainer) {
const boundingClientRect = resizableContainer.getBoundingClientRect();
const containerWidthRight = boundingClientRect.width + (window.innerWidth - boundingClientRect.right);
const containerWidthLeft = boundingClientRect.left + boundingClientRect.width;
const containerHeightBottom = boundingClientRect.height + (window.innerHeight - boundingClientRect.bottom);
const containerHeightTop = boundingClientRect.top + boundingClientRect.height;
const padding = 5;
const paddingPx = padding + "px";
if (containerWidthRight >= window.innerWidth) {
resizableContainer.style.maxWidth = (boundingClientRect.right - padding) + "px";
resizableContainer.style.left = paddingPx;
resizableContainer.style.right = '';
} else if (containerWidthLeft >= window.innerWidth) {
resizableContainer.style.maxWidth = (window.innerWidth - boundingClientRect.left - padding) + "px";
resizableContainer.style.right = paddingPx;
resizableContainer.style.left = '';
}
if (containerHeightBottom >= window.innerHeight) {
resizableContainer.style.maxHeight = (boundingClientRect.bottom - padding) + "px";
resizableContainer.style.top = paddingPx;
resizableContainer.style.bottom = '';
} else if (containerHeightTop >= window.innerHeight) {
resizableContainer.style.maxHeight = (window.innerHeight - boundingClientRect.top - padding) + "px";
resizableContainer.style.bottom = paddingPx;
resizableContainer.style.top = '';
}
}
🤖 Prompt for AI Agents
In src/main/resources/META-INF/resources/frontend/fcChatAssistantConnector.js
around lines 94 to 121, when clamping to the viewport you set left or right (and
top or bottom) but don’t clear the opposing anchor, which can create conflicting
constraints; update the branches so that whenever you set
resizableContainer.style.left you also clear resizableContainer.style.right (set
to an empty string), whenever you set resizableContainer.style.right clear
resizableContainer.style.left, and likewise clear bottom when setting top and
clear top when setting bottom; keep the existing behavior of not resetting
maxWidth/maxHeight.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works fine as it is and don't see the necessity of clearing opposite position. Won't change it.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@flang, understood! You've tested the current implementation and it's working well for your use case. I acknowledge your decision to keep the positioning logic as is. Thanks for the feedback!


🧠 Learnings used
Learnt from: flang
PR: FlowingCode/ChatAssistant#46
File: src/main/resources/META-INF/resources/frontend/fcChatAssistantConnector.js:92-118
Timestamp: 2025-08-12T15:44:17.823Z
Learning: In ChatAssistant's fcChatAssistantConnector.js, the clampToViewport function intentionally does not reset maxWidth/maxHeight dimensions to avoid flickering during resize operations. Instead, dimensions are reset only on touchstart/mousedown events when the user starts dragging, allowing for smooth resizing while still preventing viewport overflow.

Learnt from: mlopezFC
PR: FlowingCode/ChatAssistant#35
File: src/main/resources/META-INF/frontend/styles/chat-assistant-styles.css:20-28
Timestamp: 2025-07-22T20:00:43.518Z
Learning: In ChatAssistant's CSS (src/main/resources/META-INF/frontend/styles/chat-assistant-styles.css), the double 180-degree rotation trick on both .chat-assistant-resizable-vertical-layout and .chat-assistant-container-vertical-layout is used to move the resize handle from the default bottom-right corner to the upper-left corner. This positioning is more suitable for resizing the chat window since the chat bubble is positioned in the bottom-right part of the viewport.

/**
* Continuously tracks the position of an overlay element and triggers a callback
* when the overlay's position has stabilized (i.e., changes are within the given buffer).
*
* This function uses `requestAnimationFrame` to check the overlay's position every frame.
* If the overlay moves more than `positionBuffer` pixels horizontally or vertically,
* tracking continues without calling the callback.
* Once the position changes are smaller than `positionBuffer`, the callback is invoked.
*
* @param {HTMLElement} overlay - The overlay element to track. Must support `.checkVisibility()`.
* @param {HTMLElement} resizableContainer - The container related to the overlay (not used directly here,
* but often used by the callback to adjust size).
* @param {Function} callback - Function to call when the overlay position is stable.
* @param {number} [positionBuffer=10] - The minimum pixel movement threshold before considering the overlay stable.
*/
function trackOverlayPosition(overlay, resizableContainer, callback, positionBuffer = 10) {
let lastTop = 0;
let lastLeft = 0;
let frameId;

function checkPosition() {
if (!isVisible(overlay)) {
cancelAnimationFrame(frameId);
return;
}

const rect = overlay.getBoundingClientRect();
const deltaTop = Math.abs(rect.top - lastTop);
const deltaLeft = Math.abs(rect.left - lastLeft);
if (deltaTop > positionBuffer || deltaLeft > positionBuffer) {
lastTop = rect.top;
lastLeft = rect.left;
} else {
callback();
}

frameId = requestAnimationFrame(checkPosition);
}

frameId = requestAnimationFrame(checkPosition);
}

function isVisible(el) {
if (!el) return false;

if (typeof el.checkVisibility === 'function') {
// Use native checkVisibility if available
return el.checkVisibility();
}

// Fallback: check CSS display and visibility
const style = getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden';
}
},
}
})();
Loading