feat(FloatingChat): refactor chat width handling to be dynamic; enhance message panel positioning and styling with glass surface effects
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => [
|
||||||
|
|||||||
Reference in New Issue
Block a user