From 2e5af72cdcdecb66d460a86f14337f285d2c841c Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:20:13 +0800 Subject: [PATCH] feat(chat): resizable chat window with animations and improved UX - Refactor store to persist raw user intent (chatWidth/chatHeight/isExpanded) with no clamp logic - Add ResizeObserver-based resize hook for dynamic container tracking - Add drag-to-resize handles (left, top, corner) with pointer capture - Expand/Restore button uses visual state (isAtMax) not internal flag - Open/close animation (scale + opacity from bottom-right) - Resize animation on button click, instant on drag (isDragging gate) - Move ChatWindow inside content area (absolute, not fixed) - Add input draft persistence, remove agent prop from message list Co-Authored-By: Claude Opus 4.6 --- .../src/components/desktop-layout.tsx | 8 +- packages/core/chat/index.ts | 2 +- packages/core/chat/store.ts | 64 +++++++-- packages/views/chat/components/chat-fab.tsx | 2 +- packages/views/chat/components/chat-input.tsx | 13 +- .../chat/components/chat-message-list.tsx | 50 +++---- .../chat/components/chat-resize-handles.tsx | 34 +++++ .../views/chat/components/chat-window.tsx | 87 ++++++++--- .../views/chat/components/use-chat-resize.ts | 135 ++++++++++++++++++ packages/views/layout/dashboard-layout.tsx | 6 +- 10 files changed, 322 insertions(+), 79 deletions(-) create mode 100644 packages/views/chat/components/chat-resize-handles.tsx create mode 100644 packages/views/chat/components/use-chat-resize.ts diff --git a/apps/desktop/src/renderer/src/components/desktop-layout.tsx b/apps/desktop/src/renderer/src/components/desktop-layout.tsx index 9d9dab9b3..e52d9b56f 100644 --- a/apps/desktop/src/renderer/src/components/desktop-layout.tsx +++ b/apps/desktop/src/renderer/src/components/desktop-layout.tsx @@ -85,17 +85,17 @@ export function DesktopShell() { > - {/* Content area with inset styling */} -
+ {/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */} +
+ +
- - ); diff --git a/packages/core/chat/index.ts b/packages/core/chat/index.ts index e5c6f068d..8f023d312 100644 --- a/packages/core/chat/index.ts +++ b/packages/core/chat/index.ts @@ -1,4 +1,4 @@ -export { createChatStore } from "./store"; +export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H } from "./store"; export type { ChatStoreOptions, ChatState, ChatTimelineItem } from "./store"; import type { createChatStore as CreateChatStoreFn } from "./store"; diff --git a/packages/core/chat/store.ts b/packages/core/chat/store.ts index 272418367..06074d924 100644 --- a/packages/core/chat/store.ts +++ b/packages/core/chat/store.ts @@ -4,6 +4,15 @@ import { getCurrentWorkspaceId, registerForWorkspaceRehydration } from "../platf const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId"; const SESSION_STORAGE_KEY = "multica:chat:activeSessionId"; +const DRAFT_KEY = "multica:chat:draft"; +const CHAT_WIDTH_KEY = "multica:chat:width"; +const CHAT_HEIGHT_KEY = "multica:chat:height"; +const CHAT_EXPANDED_KEY = "multica:chat:expanded"; + +export const CHAT_MIN_W = 360; +export const CHAT_MIN_H = 480; +export const CHAT_DEFAULT_W = 420; +export const CHAT_DEFAULT_H = 600; export interface ChatTimelineItem { seq: number; @@ -16,21 +25,29 @@ export interface ChatTimelineItem { export interface ChatState { isOpen: boolean; - isFullscreen: boolean; activeSessionId: string | null; pendingTaskId: string | null; selectedAgentId: string | null; showHistory: boolean; timelineItems: ChatTimelineItem[]; + inputDraft: string; + /** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */ + chatWidth: number; + chatHeight: number; + isExpanded: boolean; setOpen: (open: boolean) => void; toggle: () => void; - toggleFullscreen: () => void; setActiveSession: (id: string | null) => void; setPendingTask: (taskId: string | null) => void; setSelectedAgentId: (id: string) => void; setShowHistory: (show: boolean) => void; addTimelineItem: (item: ChatTimelineItem) => void; clearTimeline: () => void; + setInputDraft: (draft: string) => void; + clearInputDraft: () => void; + /** Persist raw size and auto-exit expanded mode. */ + setChatSize: (width: number, height: number) => void; + setExpanded: (expanded: boolean) => void; } export interface ChatStoreOptions { @@ -47,20 +64,17 @@ export function createChatStore(options: ChatStoreOptions) { const store = create((set) => ({ isOpen: false, - isFullscreen: false, activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)), pendingTaskId: null, selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)), showHistory: false, timelineItems: [], - setOpen: (open) => - set({ isOpen: open, ...(open ? {} : { isFullscreen: false }) }), - toggle: () => - set((s) => ({ - isOpen: !s.isOpen, - ...(s.isOpen ? { isFullscreen: false } : {}), - })), - toggleFullscreen: () => set((s) => ({ isFullscreen: !s.isFullscreen })), + inputDraft: storage.getItem(wsKey(DRAFT_KEY)) ?? "", + chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W, + chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H, + isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true", + setOpen: (open) => set({ isOpen: open }), + toggle: () => set((s) => ({ isOpen: !s.isOpen })), setActiveSession: (id) => { if (id) { storage.setItem(wsKey(SESSION_STORAGE_KEY), id); @@ -75,6 +89,18 @@ export function createChatStore(options: ChatStoreOptions) { set({ selectedAgentId: id }); }, setShowHistory: (show) => set({ showHistory: show }), + setInputDraft: (draft) => { + if (draft) { + storage.setItem(wsKey(DRAFT_KEY), draft); + } else { + storage.removeItem(wsKey(DRAFT_KEY)); + } + set({ inputDraft: draft }); + }, + clearInputDraft: () => { + storage.removeItem(wsKey(DRAFT_KEY)); + set({ inputDraft: "" }); + }, addTimelineItem: (item) => set((s) => { if (s.timelineItems.some((t) => t.seq === item.seq)) return s; @@ -85,12 +111,28 @@ export function createChatStore(options: ChatStoreOptions) { }; }), clearTimeline: () => set({ timelineItems: [] }), + setChatSize: (w, h) => { + storage.setItem(CHAT_WIDTH_KEY, String(w)); + storage.setItem(CHAT_HEIGHT_KEY, String(h)); + // Dragging = user chose a manual size → exit expanded mode + storage.removeItem(wsKey(CHAT_EXPANDED_KEY)); + set({ chatWidth: w, chatHeight: h, isExpanded: false }); + }, + setExpanded: (expanded) => { + if (expanded) { + storage.setItem(wsKey(CHAT_EXPANDED_KEY), "true"); + } else { + storage.removeItem(wsKey(CHAT_EXPANDED_KEY)); + } + set({ isExpanded: expanded }); + }, })); registerForWorkspaceRehydration(() => { store.setState({ activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)), selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)), + inputDraft: storage.getItem(wsKey(DRAFT_KEY)) ?? "", timelineItems: [], }); }); diff --git a/packages/views/chat/components/chat-fab.tsx b/packages/views/chat/components/chat-fab.tsx index 70ba538cb..bd0256e94 100644 --- a/packages/views/chat/components/chat-fab.tsx +++ b/packages/views/chat/components/chat-fab.tsx @@ -18,7 +18,7 @@ export function ChatFab() { diff --git a/packages/views/chat/components/chat-input.tsx b/packages/views/chat/components/chat-input.tsx index 003cda931..96be7e089 100644 --- a/packages/views/chat/components/chat-input.tsx +++ b/packages/views/chat/components/chat-input.tsx @@ -3,6 +3,7 @@ import { useRef, useState } from "react"; import { ContentEditor, type ContentEditorRef } from "../../editor"; import { SubmitButton } from "@multica/ui/components/common/submit-button"; +import { useChatStore } from "@multica/core/chat"; interface ChatInputProps { onSend: (content: string) => void; @@ -13,13 +14,17 @@ interface ChatInputProps { export function ChatInput({ onSend, onStop, isRunning, disabled }: ChatInputProps) { const editorRef = useRef(null); - const [isEmpty, setIsEmpty] = useState(true); + const inputDraft = useChatStore((s) => s.inputDraft); + const setInputDraft = useChatStore((s) => s.setInputDraft); + const clearInputDraft = useChatStore((s) => s.clearInputDraft); + const [isEmpty, setIsEmpty] = useState(!inputDraft.trim()); const handleSend = () => { const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim(); if (!content || isRunning || disabled) return; onSend(content); editorRef.current?.clearContent(); + clearInputDraft(); setIsEmpty(true); }; @@ -29,8 +34,12 @@ export function ChatInput({ onSend, onStop, isRunning, disabled }: ChatInputProp
setIsEmpty(!md.trim())} + onUpdate={(md) => { + setIsEmpty(!md.trim()); + setInputDraft(md); + }} onSubmit={handleSend} debounceMs={100} /> diff --git a/packages/views/chat/components/chat-message-list.tsx b/packages/views/chat/components/chat-message-list.tsx index b7a680eb9..32951deb0 100644 --- a/packages/views/chat/components/chat-message-list.tsx +++ b/packages/views/chat/components/chat-message-list.tsx @@ -1,47 +1,48 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import { cn } from "@multica/ui/lib/utils"; -import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@multica/ui/components/ui/collapsible"; -import { Bot, Loader2, ChevronRight, ChevronDown, Brain, AlertCircle } from "lucide-react"; +import { Loader2, ChevronRight, ChevronDown, Brain, AlertCircle } from "lucide-react"; +import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade"; +import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll"; import { api } from "@multica/core/api"; import { Markdown } from "@multica/views/common/markdown"; -import type { ChatMessage, Agent, TaskMessagePayload } from "@multica/core/types"; +import type { ChatMessage, TaskMessagePayload } from "@multica/core/types"; import type { ChatTimelineItem } from "@multica/core/chat"; // ─── Public component ──────────────────────────────────────────────────── interface ChatMessageListProps { messages: ChatMessage[]; - agent: Agent | null; timelineItems: ChatTimelineItem[]; isWaiting: boolean; } export function ChatMessageList({ messages, - agent, timelineItems, isWaiting, }: ChatMessageListProps) { - const bottomRef = useRef(null); - - useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages, timelineItems]); + const scrollRef = useRef(null); + const fadeStyle = useScrollFade(scrollRef); + useAutoScroll(scrollRef); const hasTimeline = timelineItems.length > 0; return ( -
+
{messages.map((msg) => ( - + ))} {/* Live streaming timeline */} {hasTimeline && ( @@ -52,20 +53,13 @@ export function ChatMessageList({ {isWaiting && !hasTimeline && ( )} -
); } // ─── Message bubbles ───────────────────────────────────────────────────── -function MessageBubble({ - message, - agent, -}: { - message: ChatMessage; - agent: Agent | null; -}) { +function MessageBubble({ message }: { message: ChatMessage }) { if (message.role === "user") { return (
@@ -76,15 +70,13 @@ function MessageBubble({ ); } - return ; + return ; } function AssistantMessage({ message, - agent, }: { message: ChatMessage; - agent: Agent | null; }) { const taskId = message.task_id; @@ -345,13 +337,3 @@ function ErrorRow({ item }: { item: ChatTimelineItem }) { // ─── Shared ────────────────────────────────────────────────────────────── -function AgentAvatar({ agent }: { agent: Agent | null }) { - return ( - - {agent?.avatar_url && } - - - - - ); -} diff --git a/packages/views/chat/components/chat-resize-handles.tsx b/packages/views/chat/components/chat-resize-handles.tsx new file mode 100644 index 000000000..df67b60c9 --- /dev/null +++ b/packages/views/chat/components/chat-resize-handles.tsx @@ -0,0 +1,34 @@ +"use client"; + +import React from "react"; + +type DragDir = "left" | "top" | "corner"; + +interface ChatResizeHandlesProps { + onDragStart: (e: React.PointerEvent, dir: DragDir) => void; +} + +export function ChatResizeHandles({ onDragStart }: ChatResizeHandlesProps) { + return ( + <> + {/* Left edge — expands width when dragged left */} +
onDragStart(e, "left")} + className="absolute left-0 top-4 bottom-0 w-1 z-10 cursor-col-resize" + /> + {/* Top edge — expands height when dragged up */} +
onDragStart(e, "top")} + className="absolute top-0 left-4 right-0 h-1 z-10 cursor-row-resize" + /> + {/* Top-left corner — expands both width and height */} +
onDragStart(e, "corner")} + className="absolute top-0 left-0 size-4 z-20 cursor-nw-resize" + /> + + ); +} diff --git a/packages/views/chat/components/chat-window.tsx b/packages/views/chat/components/chat-window.tsx index 50572f920..682b8bb37 100644 --- a/packages/views/chat/components/chat-window.tsx +++ b/packages/views/chat/components/chat-window.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef } from "react"; +import React, { useCallback, useEffect, useRef } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Minus, Maximize2, Minimize2, Send, ChevronDown, Bot, Plus, History } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar"; @@ -8,7 +8,10 @@ import { Button } from "@multica/ui/components/ui/button"; import { DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@multica/ui/components/ui/dropdown-menu"; import { useWorkspaceId } from "@multica/core/hooks"; @@ -27,19 +30,19 @@ import { useChatStore } from "@multica/core/chat"; import { ChatMessageList } from "./chat-message-list"; import { ChatInput } from "./chat-input"; import { ChatSessionHistory } from "./chat-session-history"; +import { ChatResizeHandles } from "./chat-resize-handles"; +import { useChatResize } from "./use-chat-resize"; import { useWS } from "@multica/core/realtime"; import type { TaskMessagePayload, ChatDonePayload, Agent, ChatMessage } from "@multica/core/types"; export function ChatWindow() { const wsId = useWorkspaceId(); const isOpen = useChatStore((s) => s.isOpen); - const isFullscreen = useChatStore((s) => s.isFullscreen); const activeSessionId = useChatStore((s) => s.activeSessionId); const pendingTaskId = useChatStore((s) => s.pendingTaskId); const timelineItems = useChatStore((s) => s.timelineItems); const selectedAgentId = useChatStore((s) => s.selectedAgentId); const setOpen = useChatStore((s) => s.setOpen); - const toggleFullscreen = useChatStore((s) => s.toggleFullscreen); const showHistory = useChatStore((s) => s.showHistory); const setActiveSession = useChatStore((s) => s.setActiveSession); const setPendingTask = useChatStore((s) => s.setPendingTask); @@ -47,7 +50,6 @@ export function ChatWindow() { const clearTimeline = useChatStore((s) => s.clearTimeline); const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId); const setShowHistory = useChatStore((s) => s.setShowHistory); - const user = useAuthStore((s) => s.user); const { data: agents = [] } = useQuery(agentListOptions(wsId)); const { data: members = [] } = useQuery(memberListOptions(wsId)); @@ -222,22 +224,36 @@ export function ChatWindow() { [setSelectedAgentId, setActiveSession], ); - if (!isOpen) return null; + const windowRef = useRef(null); + const { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag } = useChatResize(windowRef); const hasMessages = messages.length > 0 || timelineItems.length > 0; - const containerClass = isFullscreen - ? "fixed top-4 right-4 bottom-4 z-50 flex flex-col w-[50%] rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden" - : "fixed bottom-4 right-4 z-50 flex flex-col w-[420px] h-[600px] rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden"; + const isVisible = isOpen && boundsReady; + + const containerClass = "absolute bottom-4 right-4 z-50 flex flex-col rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden"; + const containerStyle: React.CSSProperties = { + width: `${renderWidth}px`, + height: `${renderHeight}px`, + opacity: isVisible ? 1 : 0, + transform: isVisible ? "scale(1)" : "scale(0.95)", + transformOrigin: "bottom right", + pointerEvents: isOpen ? "auto" : "none", + transition: isDragging + ? "none" + : "width 200ms ease-out, height 200ms ease-out, opacity 150ms ease-out, transform 150ms ease-out", + }; return ( -
+
+ {/* Header */} {!showHistory && (
@@ -267,10 +283,10 @@ export function ChatWindow() { variant="ghost" size="icon-sm" className="text-muted-foreground" - onClick={toggleFullscreen} - title={isFullscreen ? "Exit fullscreen" : "Fullscreen"} + onClick={toggleExpand} + title={isAtMax ? "Restore" : "Expand"} > - {isFullscreen ? : } + {isAtMax ? : }