Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
DeerFlow frontend visual overhaul: - Card-style input box (white rounded card, textarea top, actions bottom) - Dropdown mode selector (闪速/思考/Pro/Ultra with icons+descriptions) - Colored quick-action chips (小惊喜/写作/研究/收集/学习) - Minimal top bar (title + token count + export) - Warm gray color system (#faf9f6 bg, #f5f4f1 sidebar, #e8e6e1 border) - DeerFlow-style sidebar (新对话/对话/智能体 nav) - Reasoning block, tool call chain, task progress visualization - Streaming text, model selector, suggestion chips components - Resizable artifact panel with drag handle - Virtualized message list for 100+ messages Bug fixes: - Stream hang: GatewayClient onclose code 1000 now calls onComplete - WebView2 textarea border: CSS !important override for UA styles - Gateway stream event handling (response/phase/tool_call types) Intelligence client: - Unified client with fallback drivers (compactor/heartbeat/identity/memory/reflection) - Gateway API types and type conversions
118 lines
3.4 KiB
TypeScript
118 lines
3.4 KiB
TypeScript
import { useRef, useEffect, useState, createContext, useContext, useMemo, type ReactNode } from 'react';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ConversationContext — shared state for child ai-elements components
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ConversationContextValue {
|
|
isStreaming: boolean;
|
|
setIsStreaming: (v: boolean) => void;
|
|
messages: unknown[];
|
|
setMessages: (msgs: unknown[]) => void;
|
|
}
|
|
|
|
const ConversationContext = createContext<ConversationContextValue | null>(null);
|
|
|
|
export function useConversationContext() {
|
|
const ctx = useContext(ConversationContext);
|
|
if (!ctx) {
|
|
throw new Error('useConversationContext must be used within ConversationProvider');
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
export function ConversationProvider({ children }: { children: ReactNode }) {
|
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
const [messages, setMessages] = useState<unknown[]>([]);
|
|
|
|
const value = useMemo(
|
|
() => ({ isStreaming, setIsStreaming, messages, setMessages }),
|
|
[isStreaming, messages],
|
|
);
|
|
|
|
return (
|
|
<ConversationContext.Provider value={value}>
|
|
{children}
|
|
</ConversationContext.Provider>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Conversation container with auto-stick-to-bottom scroll behavior
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Conversation container with auto-stick-to-bottom scroll behavior.
|
|
*
|
|
* Inspired by DeerFlow's use-stick-to-bottom pattern:
|
|
* - Stays pinned to bottom during streaming
|
|
* - Remembers user's scroll position when they scroll up
|
|
* - Auto-scrolls back to bottom on new content when near the bottom
|
|
*/
|
|
interface ConversationProps {
|
|
children: ReactNode;
|
|
className?: string;
|
|
}
|
|
|
|
const SCROLL_THRESHOLD = 80; // px from bottom to consider "at bottom"
|
|
|
|
export function Conversation({ children, className = '' }: ConversationProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const isAtBottomRef = useRef(true);
|
|
const observerRef = useRef<ResizeObserver | null>(null);
|
|
|
|
// Track whether user is near the bottom
|
|
const handleScroll = () => {
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
isAtBottomRef.current = distanceFromBottom < SCROLL_THRESHOLD;
|
|
};
|
|
|
|
// Auto-scroll to bottom when content changes and user is at bottom
|
|
useEffect(() => {
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
|
|
observerRef.current = new ResizeObserver(() => {
|
|
if (isAtBottomRef.current) {
|
|
el.scrollTop = el.scrollHeight;
|
|
}
|
|
});
|
|
|
|
observerRef.current.observe(el);
|
|
|
|
return () => {
|
|
observerRef.current?.disconnect();
|
|
};
|
|
}, []);
|
|
|
|
// Also observe child list changes (new messages)
|
|
useEffect(() => {
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
|
|
const mutationObserver = new MutationObserver(() => {
|
|
if (isAtBottomRef.current) {
|
|
el.scrollTop = el.scrollHeight;
|
|
}
|
|
});
|
|
|
|
mutationObserver.observe(el, { childList: true, subtree: true });
|
|
|
|
return () => {
|
|
mutationObserver.disconnect();
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
onScroll={handleScroll}
|
|
className={`overflow-y-auto custom-scrollbar ${className}`}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|