feat(FloatingChat): refactor chat width handling to be dynamic; enhance message panel positioning and styling with glass surface effects

This commit is contained in:
Roberto Musso
2026-02-28 23:30:47 +01:00
parent f767bb5175
commit cdf9a8bf18
4 changed files with 122 additions and 32 deletions

View File

@@ -6,7 +6,7 @@ import { X, ArrowUp } from 'lucide-react';
import { import {
useFloatingChat, useFloatingChat,
computeDualAnchor, computeDualAnchor,
CHAT_WIDTH, getChatWidth,
CHAT_HEIGHT, CHAT_HEIGHT,
PADDING, PADDING,
} from '@/context/FloatingChatContext'; } from '@/context/FloatingChatContext';
@@ -66,7 +66,7 @@ function FloatingChatInner() {
if (route === 'project' && state.projectId) { if (route === 'project' && state.projectId) {
// Navigate to the project page (stay on same project) // Navigate to the project page (stay on same project)
// Project sections re-register on mount and pendingSection will auto-open // Project sections re-register on mount and pendingSection will auto-open
void navigate({ to: '/projects/$projectId', params: { projectId: state.projectId } }); void navigate({ to: '/projects', search: { projectId: state.projectId } });
} else if (route.startsWith('/')) { } else if (route.startsWith('/')) {
void navigate({ to: route }); void navigate({ to: route });
} }
@@ -154,7 +154,7 @@ function FloatingChatInner() {
if (el) { if (el) {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
if (rect.right > window.innerWidth || rect.bottom > window.innerHeight) { if (rect.right > window.innerWidth || rect.bottom > window.innerHeight) {
el.style.left = `${Math.max(PADDING, Math.min(state.position.x, window.innerWidth - CHAT_WIDTH - PADDING))}px`; el.style.left = `${Math.max(PADDING, Math.min(state.position.x, window.innerWidth - getChatWidth() - PADDING))}px`;
el.style.top = `${Math.max(PADDING, Math.min(state.position.y, window.innerHeight - CHAT_HEIGHT - PADDING))}px`; el.style.top = `${Math.max(PADDING, Math.min(state.position.y, window.innerHeight - CHAT_HEIGHT - PADDING))}px`;
} }
} }
@@ -241,6 +241,10 @@ function FloatingChatInner() {
const hasMessages = messages.length > 0 || isStreaming; const hasMessages = messages.length > 0 || isStreaming;
// Expand the messages panel upward if there's enough space above the input bar,
// otherwise expand downward. 320px = 300px max-h + 8px gap + 12px buffer.
const expandUp = state.position.y >= 320;
return ( return (
<AnimatePresence> <AnimatePresence>
{state.isOpen && ( {state.isOpen && (
@@ -260,30 +264,37 @@ function FloatingChatInner() {
width: state.position.width, width: state.position.width,
zIndex: 9999, zIndex: 9999,
}} }}
className="flex flex-col gap-2" className="relative"
> >
{/* ---- Messages panel (appears when chat has content) ---- */} {/* ---- Messages panel — floats above or below the input bar ---- */}
<AnimatePresence> <AnimatePresence>
{hasMessages && ( {hasMessages && (
<motion.div <motion.div
key="messages-panel" key="messages-panel"
initial={{ opacity: 0, height: 0, scale: 0.97 }} initial={{ opacity: 0, scale: 0.97, y: expandUp ? 8 : -8 }}
animate={{ opacity: 1, height: 'auto', scale: 1 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, height: 0, scale: 0.97 }} exit={{ opacity: 0, scale: 0.97, y: expandUp ? 8 : -8 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }} transition={{ type: 'spring', stiffness: 400, damping: 30 }}
className="rounded-2xl" style={{
position: 'absolute',
width: '100%',
...(expandUp
? { bottom: 'calc(100% + 8px)' }
: { top: 'calc(100% + 8px)' }),
}}
className="rounded-2xl overflow-hidden"
> >
<div <div
ref={scrollRef} ref={scrollRef}
className="max-h-[300px] overflow-y-auto rounded-2xl [&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border" className="max-h-[300px] overflow-y-auto rounded-2xl [&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/40"
> >
<div className="flex flex-col gap-2.5 p-3"> <div className="flex flex-col gap-2.5 p-3">
{messages.map((msg) => { {messages.map((msg) => {
if (msg.role === 'user') { if (msg.role === 'user') {
return ( return (
<div key={msg.id} className="flex justify-end"> <div key={msg.id} className="flex justify-end">
<div className="max-w-[80%] rounded-2xl rounded-br-md bg-accent text-primary-foreground px-3.5 py-2 shadow-sm"> <div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-br-md px-3.5 py-2">
<p className="text-xs whitespace-pre-wrap leading-relaxed"> <p className="text-xs whitespace-pre-wrap leading-relaxed text-foreground">
{msg.content} {msg.content}
</p> </p>
</div> </div>
@@ -294,7 +305,7 @@ function FloatingChatInner() {
if (msg.error) { if (msg.error) {
return ( return (
<div key={msg.id} className="flex justify-start"> <div key={msg.id} className="flex justify-start">
<div className="max-w-[80%] rounded-2xl rounded-bl-md bg-destructive/10 px-3.5 py-2"> <div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2 !border-destructive/30">
<p className="text-xs text-destructive whitespace-pre-wrap leading-relaxed"> <p className="text-xs text-destructive whitespace-pre-wrap leading-relaxed">
{msg.content} {msg.content}
</p> </p>
@@ -305,8 +316,8 @@ function FloatingChatInner() {
return ( return (
<div key={msg.id} className="flex justify-start"> <div key={msg.id} className="flex justify-start">
<div className="max-w-[80%] rounded-2xl rounded-bl-md bg-primary text-primary-foreground px-3.5 py-2"> <div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2">
<div className="text-xs"> <div className="text-xs text-foreground">
<ChatMarkdown content={msg.content} /> <ChatMarkdown content={msg.content} />
</div> </div>
</div> </div>
@@ -317,9 +328,9 @@ function FloatingChatInner() {
{/* Streaming */} {/* Streaming */}
{isStreaming && ( {isStreaming && (
<div className="flex justify-start"> <div className="flex justify-start">
<div className="max-w-[80%] rounded-2xl rounded-bl-md bg-primary text-primary-foreground px-3.5 py-2"> <div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2">
{streamingContent ? ( {streamingContent ? (
<div className="text-xs"> <div className="text-xs text-foreground">
<ChatMarkdown content={streamingContent} /> <ChatMarkdown content={streamingContent} />
</div> </div>
) : ( ) : (
@@ -338,7 +349,7 @@ function FloatingChatInner() {
</AnimatePresence> </AnimatePresence>
{/* ---- Floating input bar ---- */} {/* ---- Floating input bar ---- */}
<div className="relative rounded-2xl bg-background/80 backdrop-blur-2xl shadow-[0_8px_60px_-12px_rgba(0,0,0,0.5)] border border-border/30 ring-1 ring-white/5 transition-shadow focus-within:shadow-[0_8px_60px_-8px_rgba(0,0,0,0.7)] focus-within:ring-ring/20"> <div className="glass-surface relative rounded-2xl transition-shadow focus-within:shadow-[0_8px_60px_-8px_rgba(0,0,0,0.35)]">
{/* Close button */} {/* Close button */}
<button <button
onClick={close} onClick={close}

View File

@@ -52,15 +52,20 @@ interface FloatingChatContextValue {
// ---------- Constants ---------- // ---------- Constants ----------
export const CHAT_WIDTH = 380; /** Dynamic chat width: 35% of viewport, clamped between 320px and 520px. */
export function getChatWidth(): number {
return Math.min(630, Math.max(320, Math.round(window.innerWidth * 0.35)));
}
export const CHAT_HEIGHT = 420; export const CHAT_HEIGHT = 420;
export const PADDING = 16; export const PADDING = 16;
// ---------- Position computation ---------- // ---------- Position computation ----------
function clampPosition(x: number, y: number): { x: number; y: number } { function clampPosition(x: number, y: number): { x: number; y: number } {
const w = getChatWidth();
return { return {
x: Math.max(PADDING, Math.min(x, window.innerWidth - CHAT_WIDTH - PADDING)), x: Math.max(PADDING, Math.min(x, window.innerWidth - w - PADDING)),
y: Math.max(PADDING, Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING)), y: Math.max(PADDING, Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING)),
}; };
} }
@@ -70,7 +75,8 @@ function computeAnchorPosition(
opts?: SectionOpenOpts, opts?: SectionOpenOpts,
): { x: number; y: number; width: number } { ): { x: number; y: number; width: number } {
const el = section.ref.current; const el = section.ref.current;
if (!el) return { x: PADDING, y: PADDING, width: CHAT_WIDTH }; const w = getChatWidth();
if (!el) return { x: PADDING, y: PADDING, width: w };
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const mode = section.anchorMode ?? 'top-right'; const mode = section.anchorMode ?? 'top-right';
@@ -80,14 +86,14 @@ function computeAnchorPosition(
const rawX = rect.right + PADDING; const rawX = rect.right + PADDING;
const rawY = opts?.clickY ?? rect.top + PADDING; const rawY = opts?.clickY ?? rect.top + PADDING;
const { x, y } = clampPosition(rawX, rawY); const { x, y } = clampPosition(rawX, rawY);
return { x, y, width: CHAT_WIDTH }; return { x, y, width: w };
} }
// Default: top-right of section // Default: top-right of section
const rawX = rect.right - CHAT_WIDTH - PADDING; const rawX = rect.right - w - PADDING;
const rawY = rect.top + PADDING; const rawY = rect.top + PADDING;
const { x, y } = clampPosition(rawX, rawY); const { x, y } = clampPosition(rawX, rawY);
return { x, y, width: CHAT_WIDTH }; return { x, y, width: w };
} }
/** /**
@@ -104,6 +110,7 @@ export function computeDualAnchor(
if (section.anchorMode === 'right-margin') return null; if (section.anchorMode === 'right-margin') return null;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const w = getChatWidth();
// Fully off-screen — freeze // Fully off-screen — freeze
if (rect.bottom < 0 || rect.top > window.innerHeight) return null; if (rect.bottom < 0 || rect.top > window.innerHeight) return null;
@@ -111,27 +118,27 @@ export function computeDualAnchor(
// Primary anchor: top-right (when section top is visible) // Primary anchor: top-right (when section top is visible)
if (rect.top >= PADDING) { if (rect.top >= PADDING) {
const { x, y } = clampPosition( const { x, y } = clampPosition(
rect.right - CHAT_WIDTH - PADDING, rect.right - w - PADDING,
rect.top + PADDING, rect.top + PADDING,
); );
return { x, y, width: CHAT_WIDTH }; return { x, y, width: w };
} }
// Fallback anchor: bottom-right (when section top scrolled off) // Fallback anchor: bottom-right (when section top scrolled off)
if (rect.bottom > CHAT_HEIGHT) { if (rect.bottom > CHAT_HEIGHT) {
const { x, y } = clampPosition( const { x, y } = clampPosition(
rect.right - CHAT_WIDTH - PADDING, rect.right - w - PADDING,
rect.bottom - CHAT_HEIGHT - PADDING, rect.bottom - CHAT_HEIGHT - PADDING,
); );
return { x, y, width: CHAT_WIDTH }; return { x, y, width: w };
} }
// Section visible but too small for fallback — clamp to top // Section visible but too small for fallback — clamp to top
const { x, y } = clampPosition( const { x, y } = clampPosition(
rect.right - CHAT_WIDTH - PADDING, rect.right - w - PADDING,
PADDING, PADDING,
); );
return { x, y, width: CHAT_WIDTH }; return { x, y, width: w };
} }
// ---------- Context ---------- // ---------- Context ----------
@@ -163,7 +170,7 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<FloatingChatState>({ const [state, setState] = useState<FloatingChatState>({
isOpen: false, isOpen: false,
activeSectionId: null, activeSectionId: null,
position: { x: 0, y: 0, width: CHAT_WIDTH }, position: { x: 0, y: 0, width: getChatWidth() },
morphTargetId: null, morphTargetId: null,
}); });

View File

@@ -184,6 +184,78 @@ body {
overflow: hidden; overflow: hidden;
} }
/* ---- Glass Surface (ReactBits-style) ---- */
/*
* Gradient border via padding-box/border-box background split —
* most reliable technique in Chromium/Electron; no pseudo-element mask needed.
*/
.glass-surface {
border: 1px solid transparent;
background:
/* glass fill — clips to padding-box (inside the border) */
rgba(255, 255, 255, 0.55) padding-box,
/* gradient border — clips to border-box (the 1px border strip) */
linear-gradient(
145deg,
rgba(255, 255, 255, 0.90) 0%,
rgba(200, 195, 205, 0.40) 40%,
rgba(200, 195, 205, 0.20) 100%
) border-box;
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
box-shadow:
0 4px 48px rgba(0, 0, 0, 0.10),
0 1px 2px rgba(0, 0, 0, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.80);
}
.dark .glass-surface {
background:
rgba(255, 255, 255, 0.05) padding-box,
linear-gradient(
145deg,
rgba(255, 255, 255, 0.18) 0%,
rgba(255, 255, 255, 0.04) 40%,
rgba(255, 255, 255, 0.08) 100%
) border-box;
box-shadow:
0 4px 48px rgba(0, 0, 0, 0.50),
0 1px 2px rgba(0, 0, 0, 0.20),
inset 0 1px 0 rgba(255, 255, 255, 0.10);
}
/* Subtle variant — same gradient border, much more transparent fill */
.glass-surface-subtle {
border: 1px solid transparent;
background:
rgba(255, 255, 255, 0.20) padding-box,
linear-gradient(
145deg,
rgba(255, 255, 255, 0.70) 0%,
rgba(200, 195, 205, 0.25) 40%,
rgba(200, 195, 205, 0.10) 100%
) border-box;
backdrop-filter: blur(16px) saturate(160%);
-webkit-backdrop-filter: blur(16px) saturate(160%);
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.60);
}
.dark .glass-surface-subtle {
background:
rgba(255, 255, 255, 0.03) padding-box,
linear-gradient(
145deg,
rgba(255, 255, 255, 0.12) 0%,
rgba(255, 255, 255, 0.02) 40%,
rgba(255, 255, 255, 0.05) 100%
) border-box;
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.30),
inset 0 1px 0 rgba(255, 255, 255, 0.07);
}
/* Crepe editor layout */ /* Crepe editor layout */
.milkdown-container { .milkdown-container {
display: flex; display: flex;

View File

@@ -68,7 +68,7 @@ export function useAIChat(defaultContext: ChatContext, options?: UseAIChatOption
const sectionMatch = finalContent.match(/^\[SECTION:([\w-]+)\]\s*/); const sectionMatch = finalContent.match(/^\[SECTION:([\w-]+)\]\s*/);
if (sectionMatch) { if (sectionMatch) {
finalContent = finalContent.slice(sectionMatch[0].length); finalContent = finalContent.slice(sectionMatch[0].length);
options?.onSectionTag?.(sectionMatch[1]); options?.onSectionTag?.(sectionMatch[1]!);
} }
setMessages((prev) => [ setMessages((prev) => [