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:
Lambda
2026-07-08 19:20:23 +08:00
parent 9ca3e45857
commit 7c7ee795fb
8 changed files with 1783 additions and 176 deletions

View File

@@ -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();

View 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

View 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 };
}

View File

@@ -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",

View File

@@ -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": "エージェント",

View File

@@ -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": "에이전트",

View File

@@ -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": "智能体",