mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 11:10:23 +02:00
fix(chat): use the original floating window, not the rewritten one (MUL-4235)
Follow-up to the merged #5080, which shipped a hand-written, simplified ChatWindow and lost the original's animations / drag-resize / expand-minimize. The floating window is just a quick entry point — it should be the original UI, not a rewrite. - Restore chat-window.tsx, chat-fab.tsx, chat-resize-handles.tsx and use-chat-resize.ts verbatim from main (0-diff): motion animations, drag resize, expand/minimize and the session dropdown are back. - Restore the empty_state.returning_subtitle + starter_prompts i18n keys the original window renders (V2 had dropped them); drop the now-unused window.open_full_tooltip key the rewrite added. - Settings gating is unchanged: FloatingChat still wraps the original FAB + window, gated by floatingChatEnabled (default off) and hidden on /chat. typecheck: core/views/web/desktop green. tests: chat + settings views 126 pass. Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -16,12 +16,6 @@ import { useT } from "../../i18n";
|
||||
|
||||
const logger = createLogger("chat.ui");
|
||||
|
||||
/**
|
||||
* Minimised floating chat button. Toggles the {@link ChatWindow} overlay open.
|
||||
* Availability is gated one level up by {@link FloatingChat} (Settings
|
||||
* preference + route), so this component only concerns itself with the
|
||||
* open/closed transition and the unread / running affordances.
|
||||
*/
|
||||
export function ChatFab() {
|
||||
const { t } = useT("chat");
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
34
packages/views/chat/components/chat-resize-handles.tsx
Normal file
34
packages/views/chat/components/chat-resize-handles.tsx
Normal file
@@ -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 */}
|
||||
<div
|
||||
aria-hidden
|
||||
onPointerDown={(e) => 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 */}
|
||||
<div
|
||||
aria-hidden
|
||||
onPointerDown={(e) => 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 */}
|
||||
<div
|
||||
aria-hidden
|
||||
onPointerDown={(e) => onDragStart(e, "corner")}
|
||||
className="absolute top-0 left-0 size-4 z-20 cursor-nw-resize"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
140
packages/views/chat/components/use-chat-resize.ts
Normal file
140
packages/views/chat/components/use-chat-resize.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
"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<HTMLDivElement | null>,
|
||||
) {
|
||||
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 = () => {
|
||||
const maxW = Math.floor(parent.clientWidth * MAX_RATIO);
|
||||
const maxH = Math.floor(parent.clientHeight * MAX_RATIO);
|
||||
setBoundsReady(true); // idempotent once true
|
||||
// Only trigger a re-render if the bounds actually changed. Without this
|
||||
// guard, any spurious ResizeObserver notification (including sub-pixel
|
||||
// layout jitter during mount) schedules a setState that feeds back into
|
||||
// the observer, producing "Maximum update depth exceeded".
|
||||
const prev = boundsRef.current;
|
||||
if (prev.maxW === maxW && prev.maxH === maxH) return;
|
||||
boundsRef.current = { maxW, maxH };
|
||||
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<DragDir, string> = {
|
||||
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 };
|
||||
}
|
||||
@@ -72,7 +72,6 @@
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"open_full_tooltip": "Open full page",
|
||||
"new_chat_tooltip": "New chat",
|
||||
"restore_tooltip": "Restore",
|
||||
"expand_tooltip": "Expand",
|
||||
@@ -97,8 +96,14 @@
|
||||
"first_time_actions": "Ask for a summary, plan your day, or hand off a quick task.",
|
||||
"returning_title_named": "Hi, I'm {{name}}",
|
||||
"returning_title_default": "Welcome to Multica",
|
||||
"returning_subtitle": "Try asking",
|
||||
"chat_with_named": "Chat with {{name}}"
|
||||
},
|
||||
"starter_prompts": {
|
||||
"list_open": "List my open tasks by priority",
|
||||
"summarize_today": "Summarize what I did today",
|
||||
"plan_next": "Plan what to work on next"
|
||||
},
|
||||
"no_agent_banner": "You need an agent to start chatting.",
|
||||
"offline_banner": {
|
||||
"fallback_name": "the agent",
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"open_full_tooltip": "全画面で開く",
|
||||
"new_chat_tooltip": "新規チャット",
|
||||
"restore_tooltip": "復元",
|
||||
"expand_tooltip": "展開",
|
||||
@@ -94,8 +93,14 @@
|
||||
"first_time_actions": "要約を頼んだり、今日の予定を立てたり、簡単なタスクを任せたりしてみましょう。",
|
||||
"returning_title_named": "こんにちは、{{name}} です",
|
||||
"returning_title_default": "Multica へようこそ",
|
||||
"returning_subtitle": "こう聞いてみましょう",
|
||||
"chat_with_named": "{{name}} とチャット"
|
||||
},
|
||||
"starter_prompts": {
|
||||
"list_open": "オープンなタスクを優先度順にまとめて",
|
||||
"summarize_today": "今日やったことを要約して",
|
||||
"plan_next": "次に取り組むことを計画して"
|
||||
},
|
||||
"no_agent_banner": "チャットを始めるにはエージェントが必要です。",
|
||||
"offline_banner": {
|
||||
"fallback_name": "エージェント",
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"open_full_tooltip": "전체 페이지 열기",
|
||||
"new_chat_tooltip": "새 채팅",
|
||||
"restore_tooltip": "복원",
|
||||
"expand_tooltip": "펼치기",
|
||||
@@ -94,8 +93,14 @@
|
||||
"first_time_actions": "요약을 요청하거나, 오늘 할 일을 계획하거나, 간단한 작업을 맡겨보세요.",
|
||||
"returning_title_named": "안녕하세요, 저는 {{name}}입니다",
|
||||
"returning_title_default": "Multica에 오신 것을 환영합니다",
|
||||
"returning_subtitle": "이렇게 요청해 보세요",
|
||||
"chat_with_named": "{{name}}와 채팅"
|
||||
},
|
||||
"starter_prompts": {
|
||||
"list_open": "열린 작업을 우선순위별로 정리해 줘",
|
||||
"summarize_today": "오늘 내가 한 일을 요약해 줘",
|
||||
"plan_next": "다음에 할 일을 계획해 줘"
|
||||
},
|
||||
"no_agent_banner": "채팅을 시작하려면 에이전트가 필요합니다.",
|
||||
"offline_banner": {
|
||||
"fallback_name": "에이전트",
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"open_full_tooltip": "打开完整页面",
|
||||
"new_chat_tooltip": "新对话",
|
||||
"restore_tooltip": "还原",
|
||||
"expand_tooltip": "展开",
|
||||
@@ -94,8 +93,14 @@
|
||||
"first_time_actions": "让它做一份总结、规划你的一天,或交给它一个小任务。",
|
||||
"returning_title_named": "你好,我是 {{name}}",
|
||||
"returning_title_default": "欢迎使用 Multica",
|
||||
"returning_subtitle": "试试问",
|
||||
"chat_with_named": "与 {{name}} 对话"
|
||||
},
|
||||
"starter_prompts": {
|
||||
"list_open": "按优先级列出我未完成的任务",
|
||||
"summarize_today": "总结一下我今天做了什么",
|
||||
"plan_next": "规划接下来该做什么"
|
||||
},
|
||||
"no_agent_banner": "需要先有一个智能体才能开始对话。",
|
||||
"offline_banner": {
|
||||
"fallback_name": "智能体",
|
||||
|
||||
Reference in New Issue
Block a user