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 ?
:
}
@@ -317,10 +332,12 @@ export function ChatWindow() {
function AgentSelector({
agents,
activeAgent,
+ userId,
onSelect,
}: {
agents: Agent[];
activeAgent: Agent | null;
+ userId: string | undefined;
onSelect: (agent: Agent) => void;
}) {
if (!activeAgent) {
@@ -336,6 +353,9 @@ function AgentSelector({
);
}
+ const myAgents = agents.filter((a) => a.owner_id === userId);
+ const othersAgents = agents.filter((a) => a.owner_id !== userId);
+
return (
@@ -344,16 +364,37 @@ function AgentSelector({
- {agents.map((agent) => (
- onSelect(agent)}
- className="flex min-w-0 items-center gap-2"
- >
-
- {agent.name}
-
- ))}
+ {myAgents.length > 0 && (
+
+ My Agents
+ {myAgents.map((agent) => (
+ onSelect(agent)}
+ className="flex min-w-0 items-center gap-2"
+ >
+
+ {agent.name}
+
+ ))}
+
+ )}
+ {myAgents.length > 0 && othersAgents.length > 0 && }
+ {othersAgents.length > 0 && (
+
+ Others
+ {othersAgents.map((agent) => (
+ onSelect(agent)}
+ className="flex min-w-0 items-center gap-2"
+ >
+
+ {agent.name}
+
+ ))}
+
+ )}
);
diff --git a/packages/views/chat/components/use-chat-resize.ts b/packages/views/chat/components/use-chat-resize.ts
new file mode 100644
index 000000000..a5f17622e
--- /dev/null
+++ b/packages/views/chat/components/use-chat-resize.ts
@@ -0,0 +1,135 @@
+"use client";
+
+import React, { useRef, useCallback, useState, useEffect } from "react";
+import { CHAT_MIN_W, CHAT_MIN_H, useChatStore } from "@multica/core/chat";
+
+type DragDir = "left" | "top" | "corner";
+
+const MAX_RATIO = 0.9;
+const FALLBACK_MAX_W = 800;
+const FALLBACK_MAX_H = 700;
+
+function clamp(v: number, min: number, max: number) {
+ return Math.max(min, Math.min(max, v));
+}
+
+export function useChatResize(
+ windowRef: React.RefObject
,
+) {
+ const chatWidth = useChatStore((s) => s.chatWidth);
+ const chatHeight = useChatStore((s) => s.chatHeight);
+ const isExpanded = useChatStore((s) => s.isExpanded);
+ const setChatSize = useChatStore((s) => s.setChatSize);
+ const setExpanded = useChatStore((s) => s.setExpanded);
+
+ // ── Container bounds via ResizeObserver ────────────────────────────────
+ const boundsRef = useRef({ maxW: FALLBACK_MAX_W, maxH: FALLBACK_MAX_H });
+ const [boundsReady, setBoundsReady] = useState(false);
+ const [isDragging, setIsDragging] = useState(false);
+ const [, setRevision] = useState(0);
+
+ useEffect(() => {
+ const el = windowRef.current;
+ const parent = el?.parentElement;
+ if (!parent) return;
+
+ const update = () => {
+ boundsRef.current = {
+ maxW: Math.floor(parent.clientWidth * MAX_RATIO),
+ maxH: Math.floor(parent.clientHeight * MAX_RATIO),
+ };
+ setBoundsReady(true);
+ setRevision((r) => r + 1);
+ };
+
+ // Measure immediately (parent is already in DOM at this point)
+ update();
+
+ const ro = new ResizeObserver(update);
+ ro.observe(parent);
+ return () => ro.disconnect();
+ }, [windowRef]);
+
+ // ── Derive rendered size ──────────────────────────────────────────────
+ const { maxW, maxH } = boundsRef.current;
+
+ const renderWidth = isExpanded ? maxW : clamp(chatWidth, CHAT_MIN_W, maxW);
+ const renderHeight = isExpanded ? maxH : clamp(chatHeight, CHAT_MIN_H, maxH);
+
+ // ── Expand / Restore ──────────────────────────────────────────────────
+ const isAtMax = renderWidth >= maxW && renderHeight >= maxH;
+
+ const toggleExpand = useCallback(() => {
+ if (isExpanded || isAtMax) {
+ setChatSize(CHAT_MIN_W, CHAT_MIN_H);
+ } else {
+ setExpanded(true);
+ }
+ }, [isExpanded, isAtMax, setChatSize, setExpanded]);
+
+ // ── Drag ──────────────────────────────────────────────────────────────
+ const dragRef = useRef<{
+ startX: number;
+ startY: number;
+ startW: number;
+ startH: number;
+ dir: DragDir;
+ } | null>(null);
+
+ const startDrag = useCallback(
+ (e: React.PointerEvent, dir: DragDir) => {
+ e.preventDefault();
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
+
+ dragRef.current = {
+ startX: e.clientX,
+ startY: e.clientY,
+ startW: renderWidth,
+ startH: renderHeight,
+ dir,
+ };
+ setIsDragging(true);
+
+ const onPointerMove = (ev: PointerEvent) => {
+ const d = dragRef.current;
+ if (!d) return;
+
+ const { maxW: mw, maxH: mh } = boundsRef.current;
+
+ const rawW =
+ dir === "left" || dir === "corner"
+ ? d.startW - (ev.clientX - d.startX)
+ : d.startW;
+ const rawH =
+ dir === "top" || dir === "corner"
+ ? d.startH - (ev.clientY - d.startY)
+ : d.startH;
+
+ setChatSize(clamp(rawW, CHAT_MIN_W, mw), clamp(rawH, CHAT_MIN_H, mh));
+ };
+
+ const onPointerUp = () => {
+ dragRef.current = null;
+ setIsDragging(false);
+ document.removeEventListener("pointermove", onPointerMove);
+ document.removeEventListener("pointerup", onPointerUp);
+ document.body.style.cursor = "";
+ document.body.style.userSelect = "";
+ };
+
+ document.addEventListener("pointermove", onPointerMove);
+ document.addEventListener("pointerup", onPointerUp);
+
+ const cursorMap: Record = {
+ left: "col-resize",
+ top: "row-resize",
+ corner: "nw-resize",
+ };
+ document.body.style.cursor = cursorMap[dir];
+ document.body.style.userSelect = "none";
+ },
+ [renderWidth, renderHeight, setChatSize],
+ );
+
+ return { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag };
+}
diff --git a/packages/views/layout/dashboard-layout.tsx b/packages/views/layout/dashboard-layout.tsx
index 3d4604b63..158ea159a 100644
--- a/packages/views/layout/dashboard-layout.tsx
+++ b/packages/views/layout/dashboard-layout.tsx
@@ -8,7 +8,7 @@ import { DashboardGuard } from "./dashboard-guard";
interface DashboardLayoutProps {
children: ReactNode;
- /** Sibling of SidebarInset (e.g. SearchCommand, ChatWindow) */
+ /** Rendered inside SidebarInset (e.g. ChatWindow, ChatFab — absolute-positioned overlays) */
extra?: ReactNode;
/** Rendered inside sidebar header as a search trigger */
searchSlot?: ReactNode;
@@ -33,14 +33,14 @@ export function DashboardLayout({
>
-
+
{children}
+ {extra}
- {extra}
);