+
- {!isNative &&
}
+ {!isNative && !useDocumentScroll && !detachedNoBorder &&
}
{
+ onScroll={useDocumentScroll ? undefined : () => {
onScroll();
if (onNativeScrollPosition && scrollRef.current) {
const el = scrollRef.current;
@@ -687,12 +705,13 @@ export function ChatViewport({
onNativeScrollPosition(distFromBottom);
}
}}
- className={`scrollbar-hide flex-1 overflow-y-auto overflow-x-hidden ${isNative ? "" : "bg-background"} ${detachedShell ? "rounded-2xl" : (!isDetached ? "pt-14" : "")}`}
- style={{ ...(isNative ? {} : { overscrollBehavior: "none" as const }), ...(detachedShell ? { boxShadow: "0 -4px 6px -1px rgb(0 0 0 / 0.06), 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)" } : {}) }}
+ className={`scrollbar-hide ${useDocumentScroll ? "overflow-visible" : "flex-1 overflow-y-auto overflow-x-hidden"} ${isNative || detachedNoBorder ? "" : "bg-background"} ${detachedShell ? "rounded-2xl" : (!isDetached ? "pt-14" : "")}`}
+ style={{ ...(isNative || useDocumentScroll ? {} : { overscrollBehavior: "none" as const }), ...(detachedShell ? { boxShadow: "0 -4px 6px -1px rgb(0 0 0 / 0.06), 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)" } : {}) }}
>
{isNative &&
}
{zenDisplayMessages.map((msg, idx) => {
+ const toolGroupHead = toolGroupMap.get(idx) ?? -1;
const side = getMessageSide(msg.role);
const prevSide = idx > 0 ? getMessageSide(zenDisplayMessages[idx - 1].role) : null;
const prevTimestamp = idx > 0 ? zenDisplayMessages[idx - 1].timestamp : null;
@@ -810,31 +829,41 @@ export function ChatViewport({
/>
)}
-
-
-
+ {toolGroupHead >= 0 && toolGroupHead !== idx ? null : (
+
+ {
+ // Merge all tool-only messages in this group into one
+ const parts: any[] = [];
+ for (let gi = idx; toolGroupMap.get(gi) === idx; gi++) {
+ const gMsg = zenDisplayMessages[gi];
+ if (Array.isArray(gMsg.content)) parts.push(...gMsg.content);
+ }
+ return { ...msg, content: parts };
+ })() : msg}
+ isStreaming={isStreaming && msg.id === effectiveStreamingId}
+ freezeStreamingLayout={freezeStreamingLayout}
+ subagentStore={subagentStore}
+ pinnedToolCallId={pinnedToolCallId}
+ onPin={onPin}
+ onUnpin={onUnpin}
+ zenMode={zenRenderMode}
+ zenGroupCollapsible={false}
+ zenGroupExpanded={zenGroupExpanded}
+ zenCollapsedByGroup={isZenSiblingRow}
+ zenGroupSlideOpen={effectiveRowSlideOpen}
+ zenGroupFadeVisible={effectiveRowFadeVisible}
+ onZenGroupToggle={undefined}
+ isSentAnim={isSentUserAnim}
+ onSentAnimationEnd={isSentUserAnim ? onSentAnimationEnd : undefined}
+ onPluginAction={onPluginAction}
+ onAddInputAttachment={onAddInputAttachment}
+ />
+
+ )}
);
})}
@@ -842,7 +871,7 @@ export function ChatViewport({
- {isDetached && !isNative &&
}
+ {isDetached && !detachedNoBorder && !isNative && !useDocumentScroll &&
}
{!isDetached && !isNative && (
;
isNativeRef: React.MutableRefObject;
scrollRef: React.RefObject;
@@ -109,6 +110,7 @@ const SLEEP_GAP_CHECK_MS = 15_000;
export function useOpenClawRuntime({
backendMode,
isNative,
+ useDocumentScroll = false,
isDetachedRef,
isNativeRef,
scrollRef,
@@ -211,7 +213,7 @@ export function useOpenClawRuntime({
backendMode,
sendWS,
sessionKeyRef,
- enabled: !isNative,
+ enabled: !isNative && !useDocumentScroll,
});
const requestHistory = useCallback(() => {
@@ -532,6 +534,13 @@ export function useOpenClawRuntime({
break;
}
+ case "retrying":
+ // Server is retrying after a rate-limit error — drop the partial
+ // assistant message so the fresh stream creates a clean one.
+ setMessages((prev) => prev.filter((m) => m.id !== payload.runId));
+ clearThinkingSource(payload.runId);
+ break;
+
case "error": {
if (activeRunIdRef.current && payload.runId && payload.runId !== activeRunIdRef.current) {
if (!queuedMessageRef.current) requestHistory();
@@ -872,6 +881,14 @@ export function useOpenClawRuntime({
return;
}
+ // Skip history refetch during an active run — the WS is already
+ // delivering realtime events, and refetching would clobber streaming
+ // state (e.g. plugin cards mounted during the run).
+ if (activeRunIdRef.current) {
+ console.log(`[WS] Resume sync (${reason}) skipped — run in progress`);
+ return;
+ }
+
const sent = requestHistory();
if (!sent) {
console.log(`[WS] Resume sync (${reason}) history request failed -> reconnect`);
diff --git a/hooks/useAppMode.ts b/hooks/useAppMode.ts
index 6c18f55..724521d 100644
--- a/hooks/useAppMode.ts
+++ b/hooks/useAppMode.ts
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { useWidgetContext } from "@mc/lib/widgetContext";
+import type { DetachedSurface } from "@mc/lib/chat/layoutMode";
/** Read a URL search param. Only call on the client. */
function getSearchParam(name: string): string | null {
@@ -32,6 +33,7 @@ function getUrlAppMode(): InitialAppMode {
export interface AppMode {
isDetached: boolean;
detachedNoBorder: boolean;
+ detachedSurface: DetachedSurface;
isNative: boolean;
uploadDisabled: boolean;
hideChrome: boolean;
@@ -41,6 +43,7 @@ export interface AppMode {
export function useAppMode(): AppMode {
const widgetCtx = useWidgetContext();
+ const detachedSurface: DetachedSurface = widgetCtx ? "widget" : "url";
// When embedded via WidgetContextProvider, use context values as initial state
// so the first render (including SSR) is already correct — no flash, no hydration mismatch.
@@ -73,9 +76,14 @@ export function useAppMode(): AppMode {
isDetachedRef.current = resolvedMode.isDetached;
isNativeRef.current = resolvedMode.isNative;
- if (resolvedMode.isDetached) {
+ const shouldUseTransparentHostBackground = widgetCtx ? widgetCtx.transparentHostBackground !== false : false;
+
+ if (resolvedMode.isDetached && shouldUseTransparentHostBackground) {
document.body.style.background = "transparent";
document.documentElement.style.background = "transparent";
+ } else if (resolvedMode.isDetached) {
+ document.body.style.background = "";
+ document.documentElement.style.background = "";
}
if (resolvedMode.isNative) {
@@ -93,6 +101,7 @@ export function useAppMode(): AppMode {
return {
isDetached: mode.isDetached,
detachedNoBorder: mode.detachedNoBorder,
+ detachedSurface,
isNative: mode.isNative,
uploadDisabled: mode.uploadDisabled,
hideChrome,
diff --git a/hooks/useIsMobileViewport.ts b/hooks/useIsMobileViewport.ts
new file mode 100644
index 0000000..a1910c2
--- /dev/null
+++ b/hooks/useIsMobileViewport.ts
@@ -0,0 +1,25 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+const MOBILE_VIEWPORT_QUERY = "(max-width: 767px)";
+
+export function useIsMobileViewport() {
+ const [isMobileViewport, setIsMobileViewport] = useState(() => {
+ if (typeof window === "undefined") return false;
+ return window.matchMedia(MOBILE_VIEWPORT_QUERY).matches;
+ });
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+
+ const media = window.matchMedia(MOBILE_VIEWPORT_QUERY);
+ const update = () => setIsMobileViewport(media.matches);
+
+ update();
+ media.addEventListener("change", update);
+ return () => media.removeEventListener("change", update);
+ }, []);
+
+ return isMobileViewport;
+}
diff --git a/hooks/useScrollManager.ts b/hooks/useScrollManager.ts
index 730010e..0e2ec6f 100644
--- a/hooks/useScrollManager.ts
+++ b/hooks/useScrollManager.ts
@@ -13,14 +13,21 @@ const STREAM_REPIN_DISTANCE_PX = 32;
* Manages scroll tracking, auto-scroll pinning, morph bar animation,
* and ResizeObserver-based content tracking.
*/
-export function useScrollManager(
- messages: Message[],
- isStreamingRef: React.RefObject,
- isNativeRef?: React.RefObject,
-) {
+export function useScrollManager({
+ messages,
+ isStreamingRef,
+ isNativeRef,
+ useDocumentScroll = false,
+}: {
+ messages: Message[];
+ isStreamingRef: React.RefObject;
+ isNativeRef?: React.RefObject;
+ useDocumentScroll?: boolean;
+}) {
const [scrollPhase, setScrollPhase] = useState<"input" | "pill">("input");
const scrollRef = useRef(null);
const bottomRef = useRef(null);
+ const footerReserveRef = useRef(null);
const morphRef = useRef(null);
const scrollRafId = useRef(null);
const scrollPhaseRef = useRef<"input" | "pill">("input");
@@ -50,6 +57,44 @@ export function useScrollManager(
const manualStreamUnpinRef = useRef(false);
const wheelUpIntentRef = useRef(0);
+ const getScrollElement = useCallback(() => {
+ if (typeof document === "undefined") return null;
+ return (useDocumentScroll ? (document.scrollingElement ?? document.documentElement) : scrollRef.current) as HTMLElement | null;
+ }, [useDocumentScroll]);
+
+ const getViewportHeight = useCallback((el: HTMLElement | null) => {
+ if (!el) return 0;
+ return el.clientHeight;
+ }, []);
+
+ const getDocumentChromeOffset = useCallback(() => {
+ if (!useDocumentScroll || typeof document === "undefined" || typeof window === "undefined") return 0;
+ const probe = document.createElement("div");
+ probe.style.cssText = "position:fixed;left:-9999px;top:0;height:100vh;width:0;pointer-events:none;";
+ document.body.appendChild(probe);
+ const layoutViewportHeight = probe.getBoundingClientRect().height;
+ probe.remove();
+ return Math.max(0, layoutViewportHeight - window.innerHeight);
+ }, [useDocumentScroll]);
+
+ const getDocumentBottomTarget = useCallback((el: HTMLElement | null) => {
+ if (!el || !useDocumentScroll || !bottomRef.current) return null;
+ const footerReserveHeight = footerReserveRef.current?.getBoundingClientRect().height ?? 0;
+ const viewportHeight = getViewportHeight(el);
+ const bottomTop = bottomRef.current.getBoundingClientRect().top + el.scrollTop;
+ const chromeOffset = getDocumentChromeOffset();
+ return Math.max(0, bottomTop - (viewportHeight - footerReserveHeight) + chromeOffset);
+ }, [getDocumentChromeOffset, getViewportHeight, useDocumentScroll]);
+
+ const getDistanceFromBottom = useCallback((el: HTMLElement | null) => {
+ if (!el) return 0;
+ const documentTarget = getDocumentBottomTarget(el);
+ if (documentTarget != null) {
+ return Math.max(0, documentTarget - el.scrollTop);
+ }
+ return el.scrollHeight - el.scrollTop - getViewportHeight(el);
+ }, [getDocumentBottomTarget, getViewportHeight]);
+
const clearManualStreamUnpin = useCallback(() => {
manualStreamUnpinRef.current = false;
wheelUpIntentRef.current = 0;
@@ -149,7 +194,7 @@ export function useScrollManager(
if (scrollRafId.current != null) return;
scrollRafId.current = requestAnimationFrame(() => {
scrollRafId.current = null;
- const el = scrollRef.current;
+ const el = getScrollElement();
if (!el) return;
// Detect container resize (keyboard open/close, viewport change).
@@ -159,12 +204,14 @@ export function useScrollManager(
// the morph locked at 0 so the input bar doesn't glitch.
// (Third-party keyboards like SwiftKey resize in multiple discrete steps,
// so this can fire several times during a single keyboard animation.)
- const currentHeight = el.clientHeight;
+ const currentHeight = getViewportHeight(el);
const heightChanged = Math.abs(currentHeight - lastClientHeightRef.current) > 2;
lastClientHeightRef.current = currentHeight;
if (heightChanged && pinnedToBottomRef.current) {
- el.scrollTop = el.scrollHeight;
+ if (!useDocumentScroll) {
+ el.scrollTop = el.scrollHeight;
+ }
setMorphTarget(0);
return;
}
@@ -176,7 +223,7 @@ export function useScrollManager(
return;
}
- const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
+ const distanceFromBottom = getDistanceFromBottom(el);
// During streaming or pin-lock window, don't unpin from scroll position —
// only the wheel/touch handlers can unpin. But DO allow re-pinning when
@@ -200,7 +247,7 @@ export function useScrollManager(
const progress = Math.min(Math.max(distanceFromBottom / range, 0), 1);
setMorphTarget(progress);
});
- }, [clearManualStreamUnpin, isStreamingRef, setMorphTarget]);
+ }, [clearManualStreamUnpin, getDistanceFromBottom, getScrollElement, getViewportHeight, isStreamingRef, setMorphTarget, useDocumentScroll]);
/** Clear any stuck bounce transform on the content div. */
const clearBounceTransform = useCallback(() => {
@@ -216,15 +263,15 @@ export function useScrollManager(
// Flag to prevent ResizeObserver from snapping scrollTop mid-animation
const isAnimatingScrollRef = useRef(false);
- const scrollToBottom = useCallback(() => {
+ const scrollToBottom = useCallback((opts?: { instant?: boolean }) => {
pinnedToBottomRef.current = true;
pinLockUntilRef.current = Date.now() + PIN_LOCK_MS;
clearManualStreamUnpin();
clearBounceTransform();
- const el = scrollRef.current;
+ const el = getScrollElement();
if (!el) return;
- const target = el.scrollHeight - el.clientHeight;
+ const target = getDocumentBottomTarget(el) ?? (el.scrollHeight - getViewportHeight(el));
const start = el.scrollTop;
const distance = target - start;
if (distance <= 0) {
@@ -233,6 +280,12 @@ export function useScrollManager(
return;
}
+ if (opts?.instant) {
+ el.scrollTop = target;
+ setMorphTarget(0);
+ return;
+ }
+
// During streaming/grace the rAF loop pins us to bottom every frame,
// so just snap — a smooth animation would be overridden anyway.
if (isStreamingRef.current || scrollGraceRef.current) {
@@ -266,27 +319,28 @@ export function useScrollManager(
};
requestAnimationFrame(animate);
- }, [clearBounceTransform, clearManualStreamUnpin, isStreamingRef, setMorphTarget]);
+ }, [clearBounceTransform, clearManualStreamUnpin, getDocumentBottomTarget, getScrollElement, getViewportHeight, isStreamingRef, setMorphTarget]);
// Auto-scroll: whenever messages change, snap to bottom if pinned.
// During streaming, the rAF loop handles smooth scrolling instead.
useLayoutEffect(() => {
if (!pinnedToBottomRef.current || messages.length === 0) return;
if (isStreamingRef.current) return;
- const el = scrollRef.current;
+ const el = getScrollElement();
if (!el) return;
if (!hasScrolledInitialRef.current) {
hasScrolledInitialRef.current = true;
}
clearBounceTransform();
- el.scrollTop = el.scrollHeight;
- }, [messages, clearBounceTransform]);
+ el.scrollTop = getDocumentBottomTarget(el) ?? el.scrollHeight;
+ }, [messages, clearBounceTransform, getDocumentBottomTarget, getScrollElement]);
// ResizeObserver: catch content-height changes (e.g. images loading, zen collapses).
useEffect(() => {
- const el = scrollRef.current;
- if (!el) return;
- const content = el.firstElementChild as HTMLElement | null;
+ const el = getScrollElement();
+ const surface = scrollRef.current;
+ if (!el || !surface) return;
+ const content = surface.firstElementChild as HTMLElement | null;
if (!content) return;
const ro = new ResizeObserver(() => {
@@ -311,17 +365,17 @@ export function useScrollManager(
content.style.minHeight = "";
}
- if ((pinnedToBottomRef.current || scrollGraceRef.current) && el.scrollHeight > el.clientHeight) {
+ if ((pinnedToBottomRef.current || scrollGraceRef.current) && el.scrollHeight > getViewportHeight(el)) {
// During grace period the rAF momentum loop is still running —
// let it smoothly catch up instead of hard-snapping.
if (!scrollGraceRef.current) {
- el.scrollTop = el.scrollHeight;
+ el.scrollTop = getDocumentBottomTarget(el) ?? el.scrollHeight;
}
pinnedToBottomRef.current = true;
} else if (!pinnedToBottomRef.current && !scrollGraceRef.current) {
// Content shrank (e.g. collapsing a tool call) while unpinned.
// If we're now back at the bottom, re-pin and clear the pill.
- const dist = el.scrollHeight - el.scrollTop - el.clientHeight;
+ const dist = getDistanceFromBottom(el);
if (dist < 80) {
pinnedToBottomRef.current = true;
setMorphTarget(Math.min(Math.max(dist / 60, 0), 1));
@@ -330,14 +384,14 @@ export function useScrollManager(
});
ro.observe(content);
return () => ro.disconnect();
- }, [setMorphTarget]);
+ }, [getDistanceFromBottom, getDocumentBottomTarget, getScrollElement, getViewportHeight, setMorphTarget]);
// rAF loop: during streaming, smoothly scroll toward bottom.
// Uses velocity with momentum — desired speed scales with gap size,
// actual velocity smoothly blends toward desired. No stutters on retarget.
// All constants are normalized to 60fps so behavior is consistent at any refresh rate.
useEffect(() => {
- const el = scrollRef.current;
+ const el = getScrollElement();
if (!el) return;
let id: number;
// velocity in px-per-60fps-frame; multiplied by frameScale before applying
@@ -377,9 +431,9 @@ export function useScrollManager(
if (
(pinnedToBottomRef.current || scrollGraceRef.current) &&
(isStreamingRef.current || scrollGraceRef.current) &&
- el.scrollHeight > el.clientHeight
+ el.scrollHeight > getViewportHeight(el)
) {
- const target = el.scrollHeight - el.clientHeight;
+ const target = getDocumentBottomTarget(el) ?? (el.scrollHeight - getViewportHeight(el));
const diff = target - el.scrollTop;
if (diff > SCROLL_MIN_DIFF) {
@@ -408,19 +462,22 @@ export function useScrollManager(
};
id = requestAnimationFrame(tick);
return () => cancelAnimationFrame(id);
- }, []);
+ }, [getDocumentBottomTarget, getScrollElement, getViewportHeight, isStreamingRef]);
// Unpin auto-scroll when user actively scrolls up (wheel or touch).
useEffect(() => {
- const el = scrollRef.current;
- if (!el) return;
+ const el = getScrollElement();
+ const eventTarget: Window | HTMLElement | null = useDocumentScroll ? window : scrollRef.current;
+ if (!el || !eventTarget) return;
let touchStartY = 0;
- const onTouchStart = (e: TouchEvent) => {
+ const onTouchStart = (event: Event) => {
+ const e = event as TouchEvent;
touchStartY = e.touches[0].clientY;
};
- const onTouchMove = (e: TouchEvent) => {
+ const onTouchMove = (event: Event) => {
+ const e = event as TouchEvent;
if (isStreamingRef.current && pinnedToBottomRef.current) {
const dy = e.touches[0].clientY - touchStartY;
if (dy > STREAM_TOUCH_UNPIN_DELTA_PX) {
@@ -430,15 +487,16 @@ export function useScrollManager(
};
const onTouchEnd = () => {
if (isStreamingRef.current && !manualStreamUnpinRef.current) {
- const dist = el.scrollHeight - el.scrollTop - el.clientHeight;
+ const dist = getDistanceFromBottom(getScrollElement());
if (dist < 80) pinnedToBottomRef.current = true;
}
};
- const onWheel = (e: WheelEvent) => {
+ const onWheel = (event: Event) => {
+ const e = event as WheelEvent;
if (isStreamingRef.current && pinnedToBottomRef.current) {
if (e.deltaY < 0) {
wheelUpIntentRef.current += Math.abs(e.deltaY);
- const dist = el.scrollHeight - el.scrollTop - el.clientHeight;
+ const dist = getDistanceFromBottom(getScrollElement());
if (dist > STREAM_UNPIN_DISTANCE_PX || wheelUpIntentRef.current >= STREAM_WHEEL_UNPIN_DELTA_PX) {
disengageStreamingAutoscroll();
}
@@ -449,17 +507,19 @@ export function useScrollManager(
};
let lastScrollTop = el.scrollTop;
const onScroll = () => {
- const currentScrollTop = el.scrollTop;
+ const metricsEl = getScrollElement();
+ if (!metricsEl) return;
+ const currentScrollTop = metricsEl.scrollTop;
// Unpin during streaming if user scrolls up
if (isStreamingRef.current && currentScrollTop < lastScrollTop - 3) {
- const dist = el.scrollHeight - currentScrollTop - el.clientHeight;
+ const dist = getDistanceFromBottom(metricsEl);
if (dist > STREAM_UNPIN_DISTANCE_PX) {
disengageStreamingAutoscroll();
}
}
// Re-pin during streaming/grace if user scrolls near bottom
if ((isStreamingRef.current || scrollGraceRef.current) && !pinnedToBottomRef.current) {
- const dist = el.scrollHeight - currentScrollTop - el.clientHeight;
+ const dist = getDistanceFromBottom(metricsEl);
const scrollingDown = currentScrollTop > lastScrollTop + 1;
const canRepin = !manualStreamUnpinRef.current
? dist < 80
@@ -473,25 +533,33 @@ export function useScrollManager(
lastScrollTop = currentScrollTop;
};
- el.addEventListener("touchstart", onTouchStart, { passive: true });
- el.addEventListener("touchmove", onTouchMove, { passive: true });
- el.addEventListener("touchend", onTouchEnd, { passive: true });
- el.addEventListener("touchcancel", onTouchEnd, { passive: true });
- el.addEventListener("wheel", onWheel, { passive: true });
- el.addEventListener("scroll", onScroll, { passive: true });
+ eventTarget.addEventListener("touchstart", onTouchStart, { passive: true });
+ eventTarget.addEventListener("touchmove", onTouchMove, { passive: true });
+ eventTarget.addEventListener("touchend", onTouchEnd, { passive: true });
+ eventTarget.addEventListener("touchcancel", onTouchEnd, { passive: true });
+ eventTarget.addEventListener("wheel", onWheel, { passive: true });
+ eventTarget.addEventListener("scroll", onScroll, { passive: true });
return () => {
- el.removeEventListener("touchstart", onTouchStart);
- el.removeEventListener("touchmove", onTouchMove);
- el.removeEventListener("touchend", onTouchEnd);
- el.removeEventListener("touchcancel", onTouchEnd);
- el.removeEventListener("wheel", onWheel);
- el.removeEventListener("scroll", onScroll);
+ eventTarget.removeEventListener("touchstart", onTouchStart);
+ eventTarget.removeEventListener("touchmove", onTouchMove);
+ eventTarget.removeEventListener("touchend", onTouchEnd);
+ eventTarget.removeEventListener("touchcancel", onTouchEnd);
+ eventTarget.removeEventListener("wheel", onWheel);
+ eventTarget.removeEventListener("scroll", onScroll);
};
- }, [clearManualStreamUnpin, disengageStreamingAutoscroll, isNativeRef, isStreamingRef]);
+ }, [clearManualStreamUnpin, disengageStreamingAutoscroll, getDistanceFromBottom, getScrollElement, isNativeRef, isStreamingRef, useDocumentScroll]);
+
+ useEffect(() => {
+ if (!useDocumentScroll) return;
+ const onWindowScroll = () => handleScroll();
+ window.addEventListener("scroll", onWindowScroll, { passive: true });
+ return () => window.removeEventListener("scroll", onWindowScroll);
+ }, [handleScroll, useDocumentScroll]);
return {
scrollRef,
bottomRef,
+ footerReserveRef,
morphRef,
scrollPhase,
pinnedToBottomRef,
diff --git a/lib/chat/layout.ts b/lib/chat/layout.ts
index d6d7c8e..92eb3d6 100644
--- a/lib/chat/layout.ts
+++ b/lib/chat/layout.ts
@@ -6,6 +6,10 @@ const BOTTOM_PAD_DETACHED_BASE = "4rem";
const BOTTOM_PAD_DETACHED_QUEUED = "7rem";
const BOTTOM_PAD_DETACHED_PINNED = "10rem";
const BOTTOM_PAD_NATIVE = "8rem";
+const BOTTOM_PAD_DOCUMENT_SCROLL = "calc(env(safe-area-inset-bottom, 0px) + 1.5rem)";
+const FOOTER_RESERVE_DOCUMENT_SCROLL_BASE = "calc(env(safe-area-inset-bottom, 0px) + 4rem)";
+const FOOTER_RESERVE_DOCUMENT_SCROLL_QUEUED = "calc(env(safe-area-inset-bottom, 0px) + 7rem)";
+const FOOTER_RESERVE_DOCUMENT_SCROLL_PINNED = "calc(env(safe-area-inset-bottom, 0px) + 10rem)";
const THINKING_INDICATOR_GAP = "1.5rem";
export const DEFAULT_INPUT_ZONE_HEIGHT = "calc(1.5dvh + 3.5rem)";
@@ -17,17 +21,20 @@ function addCalc(...parts: string[]): string {
export function getChatBottomPad({
isNative,
isDetached,
+ useDocumentScroll = false,
inputZoneHeight = DEFAULT_INPUT_ZONE_HEIGHT,
hasQueued,
hasPinnedSubagent,
}: {
isNative: boolean;
isDetached: boolean;
+ useDocumentScroll?: boolean;
inputZoneHeight?: string;
hasQueued: boolean;
hasPinnedSubagent: boolean;
}): string {
if (isNative) return BOTTOM_PAD_NATIVE;
+ if (useDocumentScroll) return BOTTOM_PAD_DOCUMENT_SCROLL;
if (isDetached) {
const overlayPad = hasPinnedSubagent
@@ -49,12 +56,29 @@ export function getChatBottomPad({
export function getThinkingIndicatorBottom({
isDetached,
+ useDocumentScroll = false,
inputZoneHeight = DEFAULT_INPUT_ZONE_HEIGHT,
}: {
isDetached: boolean;
+ useDocumentScroll?: boolean;
inputZoneHeight?: string;
}): string {
+ if (useDocumentScroll) {
+ return addCalc(BOTTOM_PAD_DOCUMENT_SCROLL, THINKING_INDICATOR_GAP);
+ }
return isDetached
? addCalc(inputZoneHeight, inputZoneHeight, THINKING_INDICATOR_GAP)
: addCalc(inputZoneHeight, THINKING_INDICATOR_GAP);
}
+
+export function getDocumentScrollFooterReserve({
+ hasQueued,
+ hasPinnedSubagent,
+}: {
+ hasQueued: boolean;
+ hasPinnedSubagent: boolean;
+}): string {
+ if (hasPinnedSubagent) return FOOTER_RESERVE_DOCUMENT_SCROLL_PINNED;
+ if (hasQueued) return FOOTER_RESERVE_DOCUMENT_SCROLL_QUEUED;
+ return FOOTER_RESERVE_DOCUMENT_SCROLL_BASE;
+}
diff --git a/lib/chat/layoutMode.ts b/lib/chat/layoutMode.ts
new file mode 100644
index 0000000..ef8f280
--- /dev/null
+++ b/lib/chat/layoutMode.ts
@@ -0,0 +1,41 @@
+export type DetachedSurface = "url" | "widget";
+
+export type ChatLayoutMode = "document-scroll" | "viewport-shell" | "parent-shell";
+
+interface ChatLayoutConfigOptions {
+ isDetached: boolean;
+ isNative: boolean;
+ isMobileViewport: boolean;
+ detachedSurface: DetachedSurface;
+}
+
+interface ChatLayoutConfig {
+ mode: ChatLayoutMode;
+ useDocumentScroll: boolean;
+ shellHeight: "100dvh" | "100%" | null;
+ useKeyboardLayout: boolean;
+}
+
+export function getChatLayoutConfig({
+ isDetached,
+ isNative,
+ isMobileViewport,
+ detachedSurface,
+}: ChatLayoutConfigOptions): ChatLayoutConfig {
+ let mode: ChatLayoutMode;
+
+ if (!isDetached || isNative) {
+ mode = "viewport-shell";
+ } else if (isMobileViewport) {
+ mode = "document-scroll";
+ } else {
+ mode = detachedSurface === "widget" ? "parent-shell" : "viewport-shell";
+ }
+
+ return {
+ mode,
+ useDocumentScroll: mode === "document-scroll",
+ shellHeight: mode === "document-scroll" ? null : mode === "parent-shell" ? "100%" : "100dvh",
+ useKeyboardLayout: !isNative && isMobileViewport && mode !== "document-scroll",
+ };
+}
diff --git a/lib/widgetContext.ts b/lib/widgetContext.ts
index fc42816..7cd9349 100644
--- a/lib/widgetContext.ts
+++ b/lib/widgetContext.ts
@@ -5,6 +5,7 @@ export interface WidgetContextValue {
noBorder: boolean;
wsUrl: string | null;
demo?: boolean;
+ transparentHostBackground?: boolean;
}
const WidgetContext = createContext(null);
diff --git a/tests/layout.test.ts b/tests/layout.test.ts
index 824d5f7..fe16469 100644
--- a/tests/layout.test.ts
+++ b/tests/layout.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { getChatBottomPad, getThinkingIndicatorBottom } from "@mc/lib/chat/layout";
+import { getChatBottomPad, getDocumentScrollFooterReserve, getThinkingIndicatorBottom } from "@mc/lib/chat/layout";
describe("chat layout spacing", () => {
it("adds composer clearance to detached bottom padding", () => {
@@ -30,10 +30,46 @@ describe("chat layout spacing", () => {
})).toBe("calc(4rem + 4rem + 1.5rem)");
});
+ it("uses a small bottom inset when document scroll owns the page", () => {
+ expect(getChatBottomPad({
+ isNative: false,
+ isDetached: true,
+ useDocumentScroll: true,
+ inputZoneHeight: "4rem",
+ hasQueued: true,
+ hasPinnedSubagent: true,
+ })).toBe("calc(env(safe-area-inset-bottom, 0px) + 1.5rem)");
+ });
+
it("keeps the fullscreen thinking indicator one composer-height above the bottom", () => {
expect(getThinkingIndicatorBottom({
isDetached: false,
inputZoneHeight: "4rem",
})).toBe("calc(4rem + 1.5rem)");
});
+
+ it("anchors the thinking indicator above the sticky composer in document scroll mode", () => {
+ expect(getThinkingIndicatorBottom({
+ isDetached: true,
+ useDocumentScroll: true,
+ inputZoneHeight: "4rem",
+ })).toBe("calc(calc(env(safe-area-inset-bottom, 0px) + 1.5rem) + 1.5rem)");
+ });
+
+ it("reserves real document space for the fixed mobile composer stack", () => {
+ expect(getDocumentScrollFooterReserve({
+ hasQueued: false,
+ hasPinnedSubagent: false,
+ })).toBe("calc(env(safe-area-inset-bottom, 0px) + 4rem)");
+
+ expect(getDocumentScrollFooterReserve({
+ hasQueued: true,
+ hasPinnedSubagent: false,
+ })).toBe("calc(env(safe-area-inset-bottom, 0px) + 7rem)");
+
+ expect(getDocumentScrollFooterReserve({
+ hasQueued: false,
+ hasPinnedSubagent: true,
+ })).toBe("calc(env(safe-area-inset-bottom, 0px) + 10rem)");
+ });
});
diff --git a/tests/layoutMode.test.ts b/tests/layoutMode.test.ts
new file mode 100644
index 0000000..15b429c
--- /dev/null
+++ b/tests/layoutMode.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+
+import { getChatLayoutConfig } from "@mc/lib/chat/layoutMode";
+
+describe("chat layout mode selection", () => {
+ it("uses document scroll for detached mobile url mode", () => {
+ expect(getChatLayoutConfig({
+ isDetached: true,
+ isNative: false,
+ isMobileViewport: true,
+ detachedSurface: "url",
+ })).toEqual({
+ mode: "document-scroll",
+ useDocumentScroll: true,
+ shellHeight: null,
+ useKeyboardLayout: false,
+ });
+ });
+
+ it("uses a viewport shell for detached desktop url mode", () => {
+ expect(getChatLayoutConfig({
+ isDetached: true,
+ isNative: false,
+ isMobileViewport: false,
+ detachedSurface: "url",
+ })).toEqual({
+ mode: "viewport-shell",
+ useDocumentScroll: false,
+ shellHeight: "100dvh",
+ useKeyboardLayout: false,
+ });
+ });
+
+ it("keeps embedded detached desktop on parent-fill shell mode", () => {
+ expect(getChatLayoutConfig({
+ isDetached: true,
+ isNative: false,
+ isMobileViewport: false,
+ detachedSurface: "widget",
+ })).toEqual({
+ mode: "parent-shell",
+ useDocumentScroll: false,
+ shellHeight: "100%",
+ useKeyboardLayout: false,
+ });
+ });
+
+ it("keeps existing non-detached and native behavior on the shell path", () => {
+ expect(getChatLayoutConfig({
+ isDetached: false,
+ isNative: false,
+ isMobileViewport: true,
+ detachedSurface: "url",
+ })).toEqual({
+ mode: "viewport-shell",
+ useDocumentScroll: false,
+ shellHeight: "100dvh",
+ useKeyboardLayout: true,
+ });
+
+ expect(getChatLayoutConfig({
+ isDetached: true,
+ isNative: true,
+ isMobileViewport: true,
+ detachedSurface: "url",
+ })).toEqual({
+ mode: "viewport-shell",
+ useDocumentScroll: false,
+ shellHeight: "100dvh",
+ useKeyboardLayout: false,
+ });
+ });
+});
diff --git a/widget.tsx b/widget.tsx
index 1b4feab..63f4637 100644
--- a/widget.tsx
+++ b/widget.tsx
@@ -12,13 +12,20 @@ export interface ChatWidgetProps {
wsUrl?: string
className?: string
demo?: boolean
+ transparentHostBackground?: boolean
}
export const ChatWidget = forwardRef(
- function ChatWidget({ wsUrl, className, demo }, ref) {
+ function ChatWidget({ wsUrl, className, demo, transparentHostBackground = true }, ref) {
const modeValue = useMemo(
- () => ({ isDetached: true, noBorder: true, wsUrl: wsUrl ?? null, demo: demo ?? false }),
- [wsUrl, demo],
+ () => ({
+ isDetached: true,
+ noBorder: true,
+ wsUrl: wsUrl ?? null,
+ demo: demo ?? false,
+ transparentHostBackground,
+ }),
+ [wsUrl, demo, transparentHostBackground],
)
return (