Skip to content
Open
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
13 changes: 13 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,16 @@
--color-sidebar-ring: var(--sidebar-ring);
}

[data-mobileclaw-embedded] {
--primary: oklch(0.313 0 0);
--primary-foreground: oklch(0.985 0 0);
}

.dark [data-mobileclaw-embedded] {
--primary: oklch(0.258 0 0);
--primary-foreground: oklch(0.911 0 0);
}

@theme {
/* Font-size scale — boosted so text-sm = 16px body baseline */
--text-2xs: 0.75rem; /* 12px */
Expand All @@ -148,6 +158,9 @@
* {
@apply border-border outline-ring/50;
}
html {
@apply bg-background text-foreground;
}
body {
@apply bg-background text-foreground;
overflow: hidden;
Expand Down
21 changes: 19 additions & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,15 @@ export const metadata: Metadata = {
// Also registers the service worker for PWA support
const headScript = `
(function() {
var isDev = ${JSON.stringify(process.env.NODE_ENV !== 'production')};
var params = new URLSearchParams(location.search);
var detached = params.has('detached');
var detachedMode = detached ? params.get('mode') : null;
var host = location.hostname;
var isIpV4 = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(host);
var isLocalHost = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host.endsWith('.local');
var isPrivateLanIp = /^10\\./.test(host) || /^192\\.168\\./.test(host) || /^172\\.(1[6-9]|2\\d|3[0-1])\\./.test(host);
var shouldDisableServiceWorker = isDev || isLocalHost || (isIpV4 && isPrivateLanIp);
try {
if (detachedMode === 'dark') {
document.documentElement.classList.add('dark');
Expand All @@ -53,8 +59,19 @@ const headScript = `
if (detached) {
document.documentElement.classList.add('detached-loading');
}
if ('serviceWorker' in navigator && location.hostname !== 'localhost' && !window.__nativeMode) {
if ('serviceWorker' in navigator && !window.__nativeMode) {
window.addEventListener('load', function() {
if (shouldDisableServiceWorker) {
navigator.serviceWorker.getRegistrations().then(function(registrations) {
registrations.forEach(function(registration) { registration.unregister(); });
});
if (window.caches && caches.keys) {
caches.keys().then(function(keys) {
keys.forEach(function(key) { caches.delete(key); });
});
}
return;
}
navigator.serviceWorker.register('/sw.js');
});
}
Expand All @@ -67,7 +84,7 @@ export default function RootLayout({
children: React.ReactNode
}>) {
return (
<html lang="en" suppressHydrationWarning>
<html lang="en" suppressHydrationWarning className="bg-background text-foreground">
<head>
<script dangerouslySetInnerHTML={{ __html: headScript }} />
</head>
Expand Down
65 changes: 60 additions & 5 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { buildDisplayMessages } from "@mc/lib/chat/messageTransforms";
import { applyNativeZenMode } from "@mc/lib/chat/zenBridge";
import { DEFAULT_INPUT_ZONE_HEIGHT, getChatBottomPad } from "@mc/lib/chat/layout";
import { getChatLayoutConfig } from "@mc/lib/chat/layoutMode";

import type {
BackendMode,
Expand All @@ -51,6 +52,7 @@ import { useZenMode } from "@mc/hooks/useZenMode";
import { useSubagentStore } from "@mc/hooks/useSubagentStore";
import { formatSessionName } from "@mc/hooks/useSessionSwitcher";
import { useAppMode } from "@mc/hooks/useAppMode";
import { useIsMobileViewport } from "@mc/hooks/useIsMobileViewport";

import { useModeBootstrap } from "@mc/hooks/chat/useModeBootstrap";
import { useOpenClawRuntime } from "@mc/hooks/chat/useOpenClawRuntime";
Expand All @@ -76,19 +78,54 @@ export default forwardRef<ChatInputHandle>(function Home(_props, forwardedRef) {
const isStreamingRef = useRef(false);
const [streamingId, setStreamingId] = useState<string | null>(null);
const [sentAnimId, setSentAnimId] = useState<string | null>(null);
const { isDetached, detachedNoBorder, isNative, uploadDisabled, hideChrome, isDetachedRef, isNativeRef } = useAppMode();
const { isDetached, detachedNoBorder, detachedSurface, isNative, uploadDisabled, hideChrome, isDetachedRef, isNativeRef } = useAppMode();
const isMobileViewport = useIsMobileViewport();
const {
useDocumentScroll,
shellHeight,
useKeyboardLayout: shouldUseKeyboardLayout,
} = getChatLayoutConfig({
isDetached,
isNative,
isMobileViewport,
detachedSurface,
});

useEffect(() => {
if (!useDocumentScroll) return;

const prevHtmlOverflow = document.documentElement.style.overflow;
const prevBodyOverflow = document.body.style.overflow;
const prevBodyOverscroll = document.body.style.overscrollBehavior;

document.documentElement.style.overflow = "visible";
document.body.style.overflow = "visible";
document.body.style.overscrollBehavior = "auto";

return () => {
document.documentElement.style.overflow = prevHtmlOverflow;
document.body.style.overflow = prevBodyOverflow;
document.body.style.overscrollBehavior = prevBodyOverscroll;
};
}, [useDocumentScroll]);

const {
scrollRef,
bottomRef,
footerReserveRef,
morphRef,
scrollPhase,
pinnedToBottomRef,
pinLockUntilRef,
handleScroll,
scrollToBottom,
updateGraceForStreamingChange,
} = useScrollManager(messages, isStreamingRef, isNativeRef);
} = useScrollManager({
messages,
isStreamingRef,
isNativeRef,
useDocumentScroll,
});

const setIsStreaming = useCallback((value: boolean) => {
const wasStreaming = isStreamingRef.current;
Expand Down Expand Up @@ -260,7 +297,7 @@ export default forwardRef<ChatInputHandle>(function Home(_props, forwardedRef) {
setTurnstileChecked(true);
}, []);

useKeyboardLayout(appRef, floatingBarRef, bottomRef, !isNative);
useKeyboardLayout(appRef, floatingBarRef, bottomRef, shouldUseKeyboardLayout);

const appendContentDelta = useCallback((runId: string, delta: string, ts: number) => {
beginContentArrival();
Expand Down Expand Up @@ -361,6 +398,7 @@ export default forwardRef<ChatInputHandle>(function Home(_props, forwardedRef) {
} = useOpenClawRuntime({
backendMode,
isNative,
useDocumentScroll,
isDetachedRef,
isNativeRef,
scrollRef,
Expand Down Expand Up @@ -702,11 +740,11 @@ export default forwardRef<ChatInputHandle>(function Home(_props, forwardedRef) {
const bottomPad = getChatBottomPad({
isNative,
isDetached,
useDocumentScroll,
inputZoneHeight,
hasQueued: !!queuedMessage,
hasPinnedSubagent: !!pinnedSubagent,
});

const lastUserMessage = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user" && !messages[i].isContext) {
Expand All @@ -716,6 +754,9 @@ export default forwardRef<ChatInputHandle>(function Home(_props, forwardedRef) {
return "";
}, [messages]);

const showAppBackground = useDocumentScroll || !hideChrome;
const shellStyle = shellHeight ? { height: shellHeight } : undefined;

if (!turnstileChecked) return null;
if (!turnstileVerified && TURNSTILE_SITE_KEY) {
return (
Expand All @@ -730,7 +771,11 @@ export default forwardRef<ChatInputHandle>(function Home(_props, forwardedRef) {
}

return (
<div ref={appRef} className={`relative flex flex-col overflow-hidden ${hideChrome ? "" : "bg-background"}`} style={{ height: isDetached ? "100%" : "100dvh" }}>
<div
ref={appRef}
className={`relative flex flex-col ${useDocumentScroll ? "min-h-svh overflow-visible" : "min-h-0 overflow-hidden"} ${showAppBackground ? "bg-background" : ""}`}
style={shellStyle}
>
<ChatChrome
hideChrome={hideChrome}
openclawUrl={openclawUrl}
Expand Down Expand Up @@ -770,6 +815,7 @@ export default forwardRef<ChatInputHandle>(function Home(_props, forwardedRef) {
isDetached={isDetached}
detachedNoBorder={detachedNoBorder}
isNative={isNative}
useDocumentScroll={useDocumentScroll}
historyLoaded={historyLoaded}
inputZoneHeight={inputZoneHeight}
bottomPad={bottomPad}
Expand Down Expand Up @@ -815,7 +861,9 @@ export default forwardRef<ChatInputHandle>(function Home(_props, forwardedRef) {
<ChatComposerBar
isNative={isNative}
isDetached={isDetached}
useDocumentScroll={useDocumentScroll}
floatingBarRef={floatingBarRef}
footerReserveRef={footerReserveRef}
morphRef={morphRef}
pinnedSubagent={pinnedSubagent}
subagentStore={subagentStore}
Expand Down Expand Up @@ -848,6 +896,13 @@ export default forwardRef<ChatInputHandle>(function Home(_props, forwardedRef) {
lastUserMessage={lastUserMessage}
uploadDisabled={uploadDisabled}
/>

{useDocumentScroll && (
<div
aria-hidden="true"
style={{ height: "calc(env(safe-area-inset-bottom, 0px) + 10dvh)", flexShrink: 0 }}
/>
)}
</div>
);
});
71 changes: 45 additions & 26 deletions components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const ChatInput = forwardRef<ChatInputHandle, {
onSend: (text: string, attachments?: ImageAttachment[]) => void;
scrollPhase?: "input" | "pill";
onScrollToBottom?: () => void;
disableScrollMorph?: boolean;
availableModels?: ModelChoice[];
modelsLoading?: boolean;
onFetchModels?: () => void;
Expand All @@ -41,6 +42,7 @@ export const ChatInput = forwardRef<ChatInputHandle, {
onSend,
scrollPhase = "input",
onScrollToBottom,
disableScrollMorph = true,
availableModels = [],
modelsLoading = false,
onFetchModels,
Expand Down Expand Up @@ -341,16 +343,20 @@ export const ChatInput = forwardRef<ChatInputHandle, {
}
};

const isPill = scrollPhase === "pill";
const isPill = !disableScrollMorph && scrollPhase === "pill";
const hasContent = !!value.trim() || attachments.length > 0;
const staticComposer = disableScrollMorph;

// maps.json is generated by gen_maps.py:
// displacement — PNG normal map (R=X, G=Y surface normals for a pill-shaped lens)
// specular — SVG rim highlight (stroke-only gradient along pill edge)
const { w: fw, h: fh } = filterDims;

return (
<div className="relative">
<div
className="relative"
style={disableScrollMorph ? ({ ["--sp"]: 0, ["--lp"]: 0 } as React.CSSProperties) : undefined}
>
{/* SVG Filters for Liquid Glass */}
<svg width="0" height="0" className="absolute pointer-events-none">
<defs>
Expand Down Expand Up @@ -472,17 +478,17 @@ export const ChatInput = forwardRef<ChatInputHandle, {

<div
className="flex items-end justify-center"
style={{ gap: "calc(8px * (1 - var(--lp, 0)))" } as React.CSSProperties}
style={{ gap: staticComposer ? 8 : "calc(8px * (1 - var(--lp, 0)))" } as React.CSSProperties}
>
{/* Image picker button — fades & collapses */}
<button
type="button"
onClick={uploadDisabled ? undefined : () => fileInputRef.current?.click()}
className={`mb-1 flex shrink-0 items-center justify-center rounded-full border border-border bg-card text-muted-foreground transition-[opacity] duration-200 overflow-hidden${uploadDisabled ? " opacity-30 cursor-not-allowed" : " hover:bg-accent hover:text-foreground"}`}
style={{
opacity: uploadDisabled ? 0.3 : "max(0, 1 - var(--sp, 0) * 2.5)",
width: "calc(40px * (1 - var(--lp, 0)))",
height: "calc(40px * (1 - var(--lp, 0)))",
opacity: uploadDisabled ? 0.3 : (staticComposer ? 1 : "max(0, 1 - var(--sp, 0) * 2.5)"),
width: staticComposer ? 40 : "calc(40px * (1 - var(--lp, 0)))",
height: staticComposer ? 40 : "calc(40px * (1 - var(--lp, 0)))",
minWidth: 0,
pointerEvents: isPill ? "none" : "auto",
} as React.CSSProperties}
Expand All @@ -503,10 +509,10 @@ export const ChatInput = forwardRef<ChatInputHandle, {
tabIndex={isPill ? 0 : undefined}
onKeyDown={isPill ? (e: React.KeyboardEvent) => { if (e.key === "Enter") onScrollToBottom?.(); } : undefined}
style={{
minHeight: "46px",
maxHeight: "calc(200px - 154px * var(--lp, 0))",
minHeight: staticComposer ? PILL_BASE_HEIGHT + 2 : `calc(${PILL_BASE_HEIGHT}px + 2px * var(--lp, 0))`,
maxHeight: staticComposer ? 200 : `calc(200px - ${200 - (PILL_BASE_HEIGHT + 2)}px * var(--lp, 0))`,
borderRadius: `${cornerRadius}px`,
transition: RADIUS_TRANSITION,
transition: staticComposer ? "none" : RADIUS_TRANSITION,
cursor: isPill ? "pointer" : "text",
background: isPill
? "oklch(from var(--background) l c h / 0.30)"
Expand All @@ -522,7 +528,7 @@ export const ChatInput = forwardRef<ChatInputHandle, {
<div
className="flex gap-1.5 overflow-x-auto px-3 pt-2.5 pb-1 scrollbar-hide"
style={{
opacity: "calc(1 - var(--sp, 0))",
opacity: staticComposer ? 1 : "calc(1 - var(--sp, 0))",
pointerEvents: isPill ? "none" : "auto",
} as React.CSSProperties}
>
Expand All @@ -543,21 +549,32 @@ export const ChatInput = forwardRef<ChatInputHandle, {
)}

{/* Pill overlay */}
<div
className="absolute inset-0 flex items-center justify-center gap-2 whitespace-nowrap text-xs font-medium"
style={{ opacity: "var(--sp, 0)", pointerEvents: isPill ? "auto" : "none", color: "var(--foreground)", filter: "drop-shadow(0 0 8px var(--background))" } as React.CSSProperties}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="m7 13 5 5 5-5" /><path d="M12 18V6" />
</svg>
<span>Scroll to bottom</span>
</div>
{!staticComposer && (
<div
className="absolute inset-0 box-border flex items-center justify-center gap-2 px-4 py-2.5 whitespace-nowrap text-xs leading-[1.75rem] font-normal"
style={{
opacity: "var(--sp, 0)",
pointerEvents: isPill ? "auto" : "none",
color: "var(--foreground)",
willChange: "opacity",
} as React.CSSProperties}
>
<span
className="flex h-4 w-4 shrink-0 items-center justify-center leading-none"
aria-hidden="true"
style={{ textShadow: "0 0 8px var(--background)", fontSize: "14px", fontWeight: 600 } as React.CSSProperties}
>
</span>
<span style={{ textShadow: "0 0 8px var(--background)" } as React.CSSProperties}>Scroll to bottom</span>
</div>
)}

{/* Textarea */}
<div
className="px-4 py-2.5 flex items-center"
style={{
opacity: "calc(1 - var(--sp, 0))",
opacity: staticComposer ? 1 : "calc(1 - var(--sp, 0))",
pointerEvents: isPill ? "none" : "auto",
} as React.CSSProperties}
>
Expand Down Expand Up @@ -597,14 +614,16 @@ export const ChatInput = forwardRef<ChatInputHandle, {
disabled={(!isActive || queueFull) && !isPill}
className="mb-1 relative shrink-0 rounded-full overflow-hidden active:scale-85"
style={{
opacity: (isActive && !queueFull)
? "max(0, 1 - var(--sp, 0) * 2.5)"
: "max(0, (1 - var(--sp, 0) * 2.5) * 0.3)",
width: "calc(40px * (1 - var(--sp, 0)))",
height: "calc(40px * (1 - var(--sp, 0)))",
opacity: staticComposer
? ((isActive && !queueFull) ? 1 : 0.3)
: ((isActive && !queueFull)
? "max(0, 1 - var(--sp, 0) * 2.5)"
: "max(0, (1 - var(--sp, 0) * 2.5) * 0.3)"),
width: staticComposer ? 40 : "calc(40px * (1 - var(--sp, 0)))",
height: staticComposer ? 40 : "calc(40px * (1 - var(--sp, 0)))",
minWidth: 0,
pointerEvents: isPill ? "none" : "auto",
transition: (isActive && !queueFull) ? "opacity 200ms, transform 200ms" : "transform 200ms",
transition: staticComposer ? "transform 200ms" : ((isActive && !queueFull) ? "opacity 200ms, transform 200ms" : "transform 200ms"),
} as React.CSSProperties}
aria-label={showStop ? "Stop" : showQueue ? "Queue" : "Send"}
>
Expand Down
Loading
Loading