From 7c7ee795fbe226ea884d427b00e9ab33cd9fd4e4 Mon Sep 17 00:00:00 2001 From: Lambda Date: Wed, 8 Jul 2026 19:20:23 +0800 Subject: [PATCH] fix(chat): use the original floating window, not the rewritten one (MUL-4235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/views/chat/components/chat-fab.tsx | 6 - .../chat/components/chat-resize-handles.tsx | 34 + .../views/chat/components/chat-window.tsx | 1751 +++++++++++++++-- .../views/chat/components/use-chat-resize.ts | 140 ++ packages/views/locales/en/chat.json | 7 +- packages/views/locales/ja/chat.json | 7 +- packages/views/locales/ko/chat.json | 7 +- packages/views/locales/zh-Hans/chat.json | 7 +- 8 files changed, 1783 insertions(+), 176 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/packages/views/chat/components/chat-fab.tsx b/packages/views/chat/components/chat-fab.tsx index 5512c262cc..8c93239159 100644 --- a/packages/views/chat/components/chat-fab.tsx +++ b/packages/views/chat/components/chat-fab.tsx @@ -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(); 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 0000000000..df67b60c9f --- /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 be386862b0..058a24fd62 100644 --- a/packages/views/chat/components/chat-window.tsx +++ b/packages/views/chat/components/chat-window.tsx @@ -1,199 +1,1618 @@ "use client"; -import { useEffect, useState } from "react"; -import { ArrowLeft, Maximize2, X } from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query"; +import { motion } from "motion/react"; +import { Minus, Maximize2, Minimize2, ChevronDown, Plus, Check, Trash2, Pencil, Loader2, Square } from "lucide-react"; import { Button } from "@multica/ui/components/ui/button"; +import { cn } from "@multica/ui/lib/utils"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"; import { - Tooltip, - TooltipTrigger, - TooltipContent, -} from "@multica/ui/components/ui/tooltip"; -import { useChatStore } from "@multica/core/chat"; + Popover, + PopoverContent, + PopoverTrigger, +} from "@multica/ui/components/ui/popover"; +import { toast } from "sonner"; import { useWorkspaceId } from "@multica/core/hooks"; -import { useWorkspacePaths } from "@multica/core/paths"; -import type { Agent, ChatSession } from "@multica/core/types"; -import { useNavigation } from "../../navigation"; -import { useT } from "../../i18n"; -import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list"; -import { ChatInput } from "./chat-input"; -import { ChatThreadList } from "./chat-thread-list"; -import { ChatSessionHeader } from "./chat-session-header"; -import { EmptyState } from "./chat-empty-state"; -import { NewChatButton } from "./new-chat-button"; +import { useAuthStore } from "@multica/core/auth"; +import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries"; +import { canAssignAgent } from "@multica/views/issues/components"; +import { api } from "@multica/core/api"; +import { useAgentPresenceDetail, useWorkspaceAgentAvailability } from "@multica/core/agents"; +import { useFileUpload } from "@multica/core/hooks/use-file-upload"; +import { ActorAvatar } from "../../common/actor-avatar"; +import { + PickerEmpty, + PickerItem, + PickerSection, + PropertyPicker, +} from "../../issues/components/pickers/property-picker"; +import { matchesPinyin } from "../../editor/extensions/pinyin-match"; import { OfflineBanner } from "./offline-banner"; import { NoAgentBanner } from "./no-agent-banner"; -import { useChatController } from "./use-chat-controller"; +import { + chatSessionsOptions, + chatMessagesPageOptions, + pendingChatTaskOptions, + pendingChatTasksOptions, + chatKeys, + isTaskMessageTaskId, +} from "@multica/core/chat/queries"; +import { + useCreateChatSession, + useDeleteChatSession, + useMarkChatSessionRead, + useUpdateChatSession, +} from "@multica/core/chat/mutations"; +import { useChatStore } from "@multica/core/chat"; +import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list"; +import { ChatInput } from "./chat-input"; +import { ChatResizeHandles } from "./chat-resize-handles"; import { useChatContextItems } from "./use-chat-context-items"; +import { useChatResize } from "./use-chat-resize"; +import { createLogger } from "@multica/core/logger"; +import type { Agent, Attachment, ChatMessage, ChatMessagesPage, ChatPendingTask, ChatSession, PendingChatTasksResponse } from "@multica/core/types"; +import { useT } from "../../i18n"; + +const uiLogger = createLogger("chat.ui"); +const apiLogger = createLogger("chat.api"); +const CHAT_VIRTUOSO_INITIAL_FIRST_ITEM_INDEX = 1_000_000; + +function appendChatMessageToLatestPageCache( + qc: ReturnType, + sessionId: string, + message: ChatMessage, +) { + qc.setQueryData>( + chatKeys.messagesPage(sessionId), + (old) => { + if (!old) { + return { + pages: [{ + messages: [message], + limit: 50, + has_more: false, + next_cursor: null, + }], + pageParams: [null], + }; + } + if (old.pages.some((page) => page.messages.some((m) => m.id === message.id))) { + return old; + } + return { + ...old, + pages: old.pages.map((page, index) => + index === 0 ? { ...page, messages: [...page.messages, message] } : page, + ), + }; + }, + ); +} + +function removeChatMessageFromPageCache( + qc: ReturnType, + sessionId: string, + messageId: string, +) { + qc.setQueryData | undefined>( + chatKeys.messagesPage(sessionId), + (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + messages: page.messages.filter((m) => m.id !== messageId), + })), + }; + }, + ); +} + +function removeChatMessageFromCaches( + qc: ReturnType, + sessionId: string, + messageId: string, +) { + qc.setQueryData( + chatKeys.messages(sessionId), + (old) => old?.filter((m) => m.id !== messageId) ?? old, + ); + removeChatMessageFromPageCache(qc, sessionId, messageId); +} + +function replaceOptimisticChatMessageId( + qc: ReturnType, + sessionId: string, + optimisticId: string, + messageId: string, + taskId: string, +) { + const replace = (messages: ChatMessage[] | undefined) => { + if (!messages) return messages; + if (messages.some((m) => m.id === messageId)) { + return messages.filter((m) => m.id !== optimisticId); + } + return messages.map((m) => + m.id === optimisticId ? { ...m, id: messageId, task_id: taskId } : m, + ); + }; + + qc.setQueryData( + chatKeys.messages(sessionId), + replace, + ); + qc.setQueryData | undefined>( + chatKeys.messagesPage(sessionId), + (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + messages: replace(page.messages) ?? page.messages, + })), + }; + }, + ); +} -/** - * Floating chat overlay — the "summon anywhere" surface. Shares all - * conversation logic with the Chat tab ({@link ChatPage}) via - * {@link useChatController}, so `activeSessionId` (the Zustand source of truth) - * stays in lockstep across both: opening or starting a thread here is - * reflected in the tab and vice-versa. Mounting / route gating lives in - * {@link FloatingChat}; this component assumes it is allowed to render. - * - * Unlike the tab, this overlay passes `contextItems`, so `@` in the composer - * surfaces the issue/project of the page you summoned it from (the "current - * context" affordance) — the whole point of a summon-anywhere entry point. - */ export function ChatWindow() { const { t } = useT("chat"); const wsId = useWorkspaceId(); - const wsPaths = useWorkspacePaths(); - const { push } = useNavigation(); const isOpen = useChatStore((s) => s.isOpen); + const activeSessionId = useChatStore((s) => s.activeSessionId); + const selectedAgentId = useChatStore((s) => s.selectedAgentId); const setOpen = useChatStore((s) => s.setOpen); + const setActiveSession = useChatStore((s) => s.setActiveSession); + const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId); + const user = useAuthStore((s) => s.user); + const { data: agents = [] } = useQuery(agentListOptions(wsId)); + const { data: members = [] } = useQuery(memberListOptions(wsId)); + // Single sessions cache — eliminates the separate active/all queries + // that used to drift during the WS-invalidate window. + const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId)); + const { + data: rawMessagePages, + isLoading: messagesLoading, + fetchNextPage: fetchOlderMessages, + hasNextPage: hasOlderMessages, + isFetchingNextPage: isFetchingOlderMessages, + } = useInfiniteQuery(chatMessagesPageOptions(activeSessionId ?? "")); + // When no active session, always show empty — don't use stale cache. + // Page 0 contains the latest chronological window; later cursor pages are + // older chronological windows. Reverse pages so older fetched pages render + // above the initial latest page. The Virtuoso firstItemIndex is client-owned: + // it starts from a large stable base and only subtracts the count of loaded + // prepended rows, so concurrent server inserts cannot drift the scroll anchor. + const messagePages = activeSessionId ? rawMessagePages?.pages ?? [] : []; + const messages = [...messagePages].reverse().flatMap((page) => page.messages); + const olderMessageCount = messagePages.slice(1).reduce((sum, page) => sum + page.messages.length, 0); + const firstItemIndex = messages.length > 0 + ? CHAT_VIRTUOSO_INITIAL_FIRST_ITEM_INDEX - olderMessageCount + : 0; + // Skeleton only shows for an un-cached session fetch. Cached switches + // return data synchronously — no flash. `enabled: false` (new chat) + // keeps isLoading false so the starter prompts aren't hidden. + const showSkeleton = !!activeSessionId && messagesLoading; + + // Server-authoritative pending task. Survives refresh / reopen / session + // switch because it's keyed on sessionId in the Query cache; WS events + // (chat:message / chat:done / task:*) keep it invalidated in real time. + // + // This is the SOLE source for pendingTaskId — no mirror in the store. + const { data: pendingTask } = useQuery( + pendingChatTaskOptions(activeSessionId ?? ""), + ); + const pendingTaskId = pendingTask?.task_id ?? null; + const stopRequestedBeforeTaskRef = useRef(false); + const [restoreDraftRequest, setRestoreDraftRequest] = useState<{ + id: string; + content: string; + attachments?: Attachment[]; + sessionId?: string; + } | null>(null); + const handleRestoreDraftConsumed = useCallback(() => { + setRestoreDraftRequest(null); + }, []); + + // Legacy archived sessions (the old soft-archive feature was removed but + // pre-existing rows with status='archived' may still exist) are excluded + // from the history dropdown. If one is still the active session, ChatInput + // is disabled and the server still rejects POST /messages for it. + const currentSession = activeSessionId + ? sessions.find((s) => s.id === activeSessionId) + : null; + const isSessionArchived = currentSession?.status === "archived"; + + const qc = useQueryClient(); + const createSession = useCreateChatSession(); + const markRead = useMarkChatSessionRead(); + + const currentMember = members.find((m) => m.user_id === user?.id); + const memberRole = currentMember?.role; + const availableAgents = agents.filter( + (a) => !a.archived_at && canAssignAgent(a, user?.id, memberRole), + ); + + // Resolve selected agent: stored preference → first available + const activeAgent = + availableAgents.find((a) => a.id === selectedAgentId) ?? + availableAgents[0] ?? + null; + + // Three-state availability — "loading" stays neutral (no banner, no + // disable) so the input doesn't flash a fake "no agent" state in the + // few hundred ms before the agent list query resolves. Only `"none"` + // (server confirmed: zero usable agents) drives the disabled UI. + const agentAvailability = useWorkspaceAgentAvailability(); + const noAgent = agentAvailability === "none"; + + // Presence drives both the avatar status dot (via ActorAvatar) and the + // OfflineBanner / TaskStatusPill availability copy. `useAgentPresenceDetail` + // returns "loading" while queries are still resolving — pass `undefined` + // downstream so banners and pill copy stay silent during loading rather + // than flash speculative offline text. + const presenceDetail = useAgentPresenceDetail(wsId, activeAgent?.id); + const availability = + presenceDetail === "loading" ? undefined : presenceDetail.availability; + + // Mount / unmount logging. ChatWindow lives in DashboardLayout, so this + // fires on layout mount (login / workspace switch / fresh page load). + useEffect(() => { + uiLogger.info("ChatWindow mount", { + isOpen, + activeSessionId, + pendingTaskId, + selectedAgentId, + wsId, + }); + return () => { + uiLogger.info("ChatWindow unmount", { + activeSessionId, + pendingTaskId, + }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- once per mount + }, []); + + // Open intent is fully driven by `activeSessionId` in storage — no mount + // restore, no self-heal. Adding either reintroduces a "two signals + // describing one fact" race (the previous self-heal mis-cleared the + // freshly-created session because allSessions was still stale during the + // post-create invalidate-refetch window). + + // WS events are handled globally in useRealtimeSync — the query cache + // stays current even when this window is closed. See packages/core/realtime/. + + // Auto mark-as-read whenever the user is looking at a session with unread + // state: window open + a session active + has_unread → PATCH. + // has_unread comes from the list query; WS handlers invalidate it on + // chat:done so a reply arriving while the user watches triggers this + // effect again and is instantly cleared. + const currentHasUnread = + sessions.find((s) => s.id === activeSessionId)?.has_unread ?? false; + useEffect(() => { + if (!isOpen || !activeSessionId) return; + if (!currentHasUnread) return; + uiLogger.info("auto markRead", { sessionId: activeSessionId }); + markRead.mutate(activeSessionId); + // eslint-disable-next-line react-hooks/exhaustive-deps -- markRead ref stable + }, [isOpen, activeSessionId, currentHasUnread]); + + const { uploadWithToast } = useFileUpload(api); + + // Lazy-creates a chat_session the first time the user needs an id — + // either to send a message or to attach an uploaded file. Pulled out of + // handleSend so the upload path (which fires before any text exists) can + // get a session_id to hang the attachment on. Returns null when no agent + // is available; callers must early-return in that case. + // + // Concurrent callers (e.g. user drops a file → handleUploadFile, then + // quickly clicks send → handleSend) would each observe activeSessionId + // === null and fire a separate createSession.mutateAsync, creating two + // sessions and orphaning the attachment on the wrong one. The in-flight + // promise ref dedupes those races: the first caller starts the create, + // every subsequent caller awaits the same promise until it settles. + // + // titleSeed is the first 50 chars of the user's message when called from + // send; the upload path passes "" and we leave the title empty so the + // session-dropdown's existing localized `window.untitled` fallback kicks + // in. A follow-up task may back-fill the real title from the first user + // message — until then this keeps the session list scannable across locales. + // + // NOTE: ensureSession does NOT flip `activeSessionId` itself. Callers must + // seed `chatKeys.messages(sessionId)` in the Query cache BEFORE calling + // `setActiveSession(sessionId)`, otherwise the first useQuery subscription + // for the new key reports `isLoading: true` and renders ChatMessageSkeleton + // for one frame (the "new-chat first-message" white flash). + const sessionPromiseRef = useRef | null>(null); + const ensureSession = useCallback( + async (titleSeed: string): Promise => { + if (activeSessionId) return activeSessionId; + if (!activeAgent) return null; + if (sessionPromiseRef.current) return sessionPromiseRef.current; + + const promise = (async () => { + try { + const session = await createSession.mutateAsync({ + agent_id: activeAgent.id, + title: titleSeed.slice(0, 50), + }); + return session.id; + } finally { + sessionPromiseRef.current = null; + } + })(); + sessionPromiseRef.current = promise; + return promise; + }, + [activeSessionId, activeAgent, createSession], + ); + + const handleUploadFile = useCallback( + async (file: File) => { + if (!activeAgent) return null; + // Uploads are workspace-scoped drafts. Sending the message is the point + // where we create a chat session (if needed) and bind attachment_ids to + // the persisted chat_message row. This keeps a paste/drop from creating + // an empty chat session the user never sends. + return uploadWithToast(file); + }, + [activeAgent, uploadWithToast], + ); + + const cancelChatTask = useCallback( + async ( + taskId: string, + sessionId: string, + options: { restoreDraftToInput: boolean; source: string }, + ) => { + apiLogger.info("cancelTask.start", { + taskId, + sessionId, + source: options.source, + }); + qc.setQueryData(chatKeys.pendingTask(sessionId), {}); + + try { + const result = await api.cancelTaskById(taskId); + const restored = result.cancelled_chat_message; + if (restored?.restore_to_input) { + removeChatMessageFromCaches(qc, restored.chat_session_id, restored.message_id); + if (options.restoreDraftToInput && restored.chat_session_id === sessionId) { + setRestoreDraftRequest({ + id: restored.message_id, + content: restored.content, + attachments: restored.attachments, + sessionId: restored.chat_session_id, + }); + } + } + qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) }); + qc.invalidateQueries({ queryKey: chatKeys.messagesPage(sessionId) }); + apiLogger.info("cancelTask.success", { + taskId, + sessionId, + restoredToInput: !!restored?.restore_to_input && options.restoreDraftToInput, + }); + return result; + } catch (err) { + apiLogger.warn("cancelTask.error (task may have already finished)", { + taskId, + sessionId, + err, + }); + qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) }); + qc.invalidateQueries({ queryKey: chatKeys.messagesPage(sessionId) }); + return null; + } + }, + [qc], + ); + + const handleSend = useCallback( + async ( + content: string, + attachmentIds?: string[], + commitInput?: (options?: { extraDraftKeys?: string[]; clearEditor?: boolean }) => void, + draftAttachments: Attachment[] = [], + ): Promise => { + if (!activeAgent) { + apiLogger.warn("sendChatMessage skipped: no active agent"); + return false; + } + + const finalContent = content; + + const isNewSession = !activeSessionId; + + apiLogger.info("sendChatMessage.start", { + sessionId: activeSessionId, + isNewSession, + agentId: activeAgent.id, + contentLength: finalContent.length, + attachmentCount: attachmentIds?.length ?? 0, + }); + + let sessionId: string | null = null; + try { + sessionId = await ensureSession(finalContent); + } catch (err) { + apiLogger.error("sendChatMessage.ensureSession.error", err); + toast.error(t(($) => $.input.send_failed_toast)); + return false; + } + if (!sessionId) { + apiLogger.warn("sendChatMessage aborted: ensureSession returned null"); + return false; + } + + // Optimistic burst — everything that gives the user "I sent a message + // and the agent is now working" feedback fires BEFORE the HTTP roundtrip. + // Pre-#status-pill the pending-task seed lived after `await + // sendChatMessage` and the pill blinked in a few hundred ms after the + // user's message — small but visible "did it actually send?" gap. + const sentAt = new Date().toISOString(); + const optimistic: ChatMessage = { + id: `optimistic-${Date.now()}`, + chat_session_id: sessionId, + role: "user", + content: finalContent, + task_id: null, + created_at: sentAt, + attachments: draftAttachments, + }; + // Seed cache BEFORE flipping activeSessionId. If we set the active + // session first, useQuery's first subscription to the new key sees no + // cached data and renders ChatMessageSkeleton for one frame — the + // "new-chat first-message" white flash. Priming the cache first means + // the very first read after activeSessionId flips hits data + // synchronously and ChatMessageList mounts directly. + appendChatMessageToLatestPageCache(qc, sessionId, optimistic); + qc.setQueryData( + chatKeys.messages(sessionId), + (old) => (old ? [...old, optimistic] : [optimistic]), + ); + // Seed the pending-task with a temporary id so the StatusPill mounts + // and starts ticking the instant the user clicks send. Real task_id + // and server-authoritative created_at land below; until then the pill + // is anchored to the local clock (drift is the request RTT, ~50–200ms, + // which doesn't change the rendered "Ns" value). + qc.setQueryData(chatKeys.pendingTask(sessionId), { + task_id: `optimistic-${optimistic.id}`, + status: "queued", + created_at: sentAt, + }); + // Cache primed → safe to publish the new active session. But only steal + // focus if the user is STILL on the compose target they sent from — if + // they navigated away mid-send, this is fire-and-forget: the reply + // surfaces via the unread dot on the sent session, we don't yank the + // view back. Compare the live store against the closure-captured target. + // For a brand-new chat (activeSessionId === null) the target is keyed by + // the selected agent, so switching agents to start a different new chat + // must also count as "navigated away" even though both sides are null. + const live = useChatStore.getState(); + const stillOnSourceSession = + live.activeSessionId === activeSessionId && + (activeSessionId !== null || live.selectedAgentId === selectedAgentId); + if (stillOnSourceSession) { + setActiveSession(sessionId); + } + commitInput?.({ extraDraftKeys: [sessionId], clearEditor: stillOnSourceSession }); + apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id }); + + let result; + try { + result = await api.sendChatMessage(sessionId, finalContent, attachmentIds); + } catch (err) { + apiLogger.error("sendChatMessage.error.rollback", { sessionId, optimisticId: optimistic.id, err }); + stopRequestedBeforeTaskRef.current = false; + removeChatMessageFromCaches(qc, sessionId, optimistic.id); + qc.setQueryData(chatKeys.pendingTask(sessionId), {}); + setRestoreDraftRequest({ + id: `send-failed-${optimistic.id}`, + content: finalContent, + attachments: draftAttachments, + // Restore into the session this was sent from. If the user + // navigated away (fire-and-forget) the request waits until they + // return rather than dumping content into another session. + sessionId, + }); + toast.error(t(($) => $.input.send_failed_toast)); + return false; + } + apiLogger.info("sendChatMessage.success", { + sessionId, + messageId: result.message_id, + taskId: result.task_id, + }); + replaceOptimisticChatMessageId(qc, sessionId, optimistic.id, result.message_id, result.task_id); + // Replace the temporary task_id with the server's real one (so the WS + // task: handlers can match against it) and snap the anchor to the + // server's created_at — keeping the elapsed-seconds reading stable. + qc.setQueryData(chatKeys.pendingTask(sessionId), { + task_id: result.task_id, + status: "queued", + created_at: result.created_at, + }); + if (stopRequestedBeforeTaskRef.current) { + stopRequestedBeforeTaskRef.current = false; + await cancelChatTask(result.task_id, sessionId, { + restoreDraftToInput: true, + source: "deferred-send", + }); + return false; + } + // The server reports which attachment ids it actually bound. Diff + // against what we requested so a silent bind failure surfaces to the + // user — no extra fetch. Skip the check on servers that predate the + // field (attachment_ids undefined) rather than false-alarm. + if (attachmentIds && attachmentIds.length > 0 && result.attachment_ids) { + const boundIds = new Set(result.attachment_ids); + const missing = attachmentIds.filter((id) => !boundIds.has(id)); + if (missing.length > 0) { + apiLogger.warn("sendChatMessage.attachments missing after send", { + sessionId, + messageId: result.message_id, + missing, + }); + toast.error(t(($) => $.input.attachment_bind_failed_toast)); + } + } + qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) }); + qc.invalidateQueries({ queryKey: chatKeys.messagesPage(sessionId) }); + return true; + }, + [ + activeSessionId, + selectedAgentId, + activeAgent, + ensureSession, + cancelChatTask, + qc, + setActiveSession, + t, + ], + ); + + const handleStop = useCallback(() => { + if (!pendingTaskId || !activeSessionId) { + apiLogger.debug("cancelTask skipped: no pending task"); + return; + } + if (!isTaskMessageTaskId(pendingTaskId)) { + stopRequestedBeforeTaskRef.current = true; + apiLogger.info("cancelTask.deferred until server task id", { + taskId: pendingTaskId, + sessionId: activeSessionId, + }); + return; + } + void cancelChatTask(pendingTaskId, activeSessionId, { + restoreDraftToInput: true, + source: "active-input", + }); + }, [pendingTaskId, activeSessionId, cancelChatTask]); + + const handleSelectAgent = useCallback( + (agent: Agent) => { + // No-op when clicking the already-active agent — don't clobber the + // current session just because the user closed the menu this way. + // Compare against activeAgent (what the UI shows), not selectedAgentId + // (which may be null / point to an archived agent on first load). + if (activeAgent && agent.id === activeAgent.id) return; + uiLogger.info("selectAgent", { + from: selectedAgentId, + to: agent.id, + previousSessionId: activeSessionId, + }); + setSelectedAgentId(agent.id); + // Reset session when switching agent + setActiveSession(null); + }, + [activeAgent, selectedAgentId, activeSessionId, setSelectedAgentId, setActiveSession], + ); + + const handleNewChat = useCallback(() => { + uiLogger.info("newChat", { + previousSessionId: activeSessionId, + previousPendingTask: pendingTaskId, + }); + setActiveSession(null); + }, [activeSessionId, pendingTaskId, setActiveSession]); + + const handleSelectSession = useCallback( + (session: ChatSession) => { + // Sessions are bound 1:1 to an agent — picking a session from a + // different agent implicitly switches the agent too. + if (activeAgent && session.agent_id !== activeAgent.id) { + uiLogger.info("selectSession (cross-agent)", { + from: activeAgent.id, + toAgent: session.agent_id, + toSession: session.id, + }); + setSelectedAgentId(session.agent_id); + } + setActiveSession(session.id); + }, + [activeAgent, setSelectedAgentId, setActiveSession], + ); + + const handleMinimize = useCallback(() => { + uiLogger.info("minimize (close)", { + activeSessionId, + pendingTaskId, + }); + setOpen(false); + }, [activeSessionId, pendingTaskId, setOpen]); + + const isExpanded = useChatStore((s) => s.isExpanded); + + const windowRef = useRef(null); + const { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag } = useChatResize(windowRef); + + // Show the list (vs empty state) as soon as there's anything to display — + // a real message, or a pending task whose timeline will stream in. + const hasMessages = messages.length > 0 || !!pendingTaskId; + + const isVisible = isOpen && (isExpanded || boundsReady); + + const containerClass = "absolute bottom-2 right-2 z-50 flex flex-col rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden"; + const containerStyle: React.CSSProperties = { + transformOrigin: "bottom right", + pointerEvents: isOpen ? "auto" : "none", + }; - const c = useChatController({ isActive: isOpen }); const contextItems = useChatContextItems(wsId); - // "Composing a brand-new chat": the user hit ⊕ but hasn't sent yet, so no - // session exists. Mirrors ChatPage; resets once a real session takes over. - const [composingNew, setComposingNew] = useState(false); - useEffect(() => { - if (c.activeSessionId) setComposingNew(false); - }, [c.activeSessionId]); + return ( + + + {/* Header — ⊕ new + session dropdown | window tools */} +
+
+ + + } + > + + + {t(($) => $.window.new_chat_tooltip)} + + +
+
+ + + } + > + {isExpanded || isAtMax ? : } + + + {isExpanded || isAtMax ? t(($) => $.window.restore_tooltip) : t(($) => $.window.expand_tooltip)} + + + + + } + > + + + {t(($) => $.window.minimize_tooltip)} + +
+
- if (!isOpen) return null; + {/* Messages / skeleton / empty state */} + {showSkeleton ? ( + + ) : hasMessages ? ( + void fetchOlderMessages()} + /> + ) : ( + 0} + agentName={activeAgent?.name} + onPickPrompt={(text) => handleSend(text)} + /> + )} - const handleSelect = (session: ChatSession) => { - c.handleSelectSession(session); - setComposingNew(false); - }; + {/* Status banner above the input — single mutually-exclusive slot. + * Priority: no-agent > offline / unstable. Agent presence is the + * hard prerequisite (you can't send anything without one), so it + * always wins over a presence hint. Recent issue/project navigation + * lives in the input action row; it is not message/session state. + * + * We key off `noAgent` (the resolved-empty state) rather than + * `!activeAgent`, so the loading window between mount and the + * first agent-list response stays banner-free. */} + {noAgent ? ( + + ) : ( + + )} - const startNewChat = (agent: Agent | null) => { - if (agent) c.handleStartNewChat(agent); - else c.handleNewChat(); - setComposingNew(true); - }; + {/* Input — disabled for legacy archived sessions; locked out entirely + * when there's no agent (the EmptyState above carries the CTA). */} + + } + contextItems={contextItems} + /> +
+ ); +} - const backToList = () => { - c.setActiveSession(null); - setComposingNew(false); - }; +/** + * Agent dropdown: avatar trigger, lists all available agents. Selecting a + * different agent = switch agent + start a fresh chat (session=null). + * The current agent is marked with a check and not clickable. + */ +export function AgentDropdown({ + agents, + activeAgent, + userId, + onSelect, +}: { + agents: Agent[]; + activeAgent: Agent | null; + userId: string | undefined; + onSelect: (agent: Agent) => void; +}) { + const { t } = useT("chat"); + const [open, setOpen] = useState(false); + const [filter, setFilter] = useState(""); + // Split into the user's own agents and everyone else so the menu groups + // them — matches the old AgentSelector layout. + const { mine, others } = useMemo(() => { + const mine: Agent[] = []; + const others: Agent[] = []; + for (const a of agents) { + if (a.owner_id === userId) mine.push(a); + else others.push(a); + } + return { mine, others }; + }, [agents, userId]); - const openFullPage = () => { - push(c.activeSessionId ? `${wsPaths.chat()}?session=${c.activeSessionId}` : wsPaths.chat()); + const query = filter.trim().toLowerCase(); + const matches = (name: string) => + !query || name.toLowerCase().includes(query) || matchesPinyin(name, query); + const filteredMine = mine.filter((agent) => matches(agent.name)); + const filteredOthers = others.filter((agent) => matches(agent.name)); + + const handlePick = (agent: Agent) => { + onSelect(agent); setOpen(false); }; - const hasTarget = !!c.activeSessionId || composingNew; - - const headerButtons = ( -
- - - - - - } - /> - {t(($) => $.window.open_full_tooltip)} - - - setOpen(false)}> - - - } - /> - {t(($) => $.window.minimize_tooltip)} - -
- ); - - const conversation = ( -
-
- -
- {c.currentSession && ( - - )} -
-
- {c.showSkeleton ? ( - - ) : c.hasMessages ? ( - void c.fetchOlderMessages()} - /> - ) : ( - - )} - - {c.noAgent ? ( - - ) : ( - - )} - - -
- ); + if (!activeAgent) { + return {t(($) => $.window.no_agents)}; + } return ( -
- {!hasTarget && ( -
-

{t(($) => $.page.title)}

- {headerButtons} -
- )} - {hasTarget ? ( + $.window.agent_filter_placeholder)} + onSearchChange={setFilter} + triggerRender={ + + +
+ ) : isConfirmingStop && pendingTask ? ( +
+ + +
+ ) : ( +
+
+ {isRunning && } + {showCompleted && !isRunning && } + {showUnread && !isRunning && !showCompleted && ( + $.window.unread)} + title={t(($) => $.window.unread)} + className="size-1.5 rounded-full bg-brand" + /> + )} + {trailingStatus} +
+
+ {isRunning && pendingTask && ( + + )} + {!isRunning && ( + <> + + + + )} +
+
+ ) + )} +
+ ); + }; + + return ( + <> + +
+ + {triggerAgent && ( + + )} + {title} + {currentSessionRunning && ( + $.session_history.row_subtitle.working)} + className="size-3 shrink-0 animate-spin text-muted-foreground" + /> + )} + + + {otherRunningCount > 0 ? ( + $.window.another_running)} + title={t(($) => $.window.another_running)} + className="inline-flex h-6 shrink-0 items-center gap-1 rounded-md px-1.5 text-xs font-medium text-muted-foreground" + > + + {otherRunningCount > 1 && {otherRunningCount}} + + ) : otherUnreadCount > 0 ? ( + $.window.another_unread)} + title={t(($) => $.window.another_unread)} + className="inline-flex h-6 shrink-0 items-center gap-1 rounded-md px-1.5 text-xs font-medium text-muted-foreground" + > + + {otherUnreadCount > 1 && {otherUnreadCount}} + + ) : null} +
+ e.stopPropagation()} + > + {historySessions.length === 0 ? ( +
+ {t(($) => $.window.no_previous)} +
+ ) : ( +
$.window.history_group)}> +
+ {t(($) => $.window.history_group)} +
+ {historySessions.map(renderRow)} +
+ )} +
+
+ + ); +} + +/** + * Inline editor for a session title. Mounts focused with the existing + * title pre-selected so the user can either replace it outright or arrow + * into the existing text. Enter commits, Escape cancels, a real click + * outside the input also commits. + * + * We do NOT commit on the input's `blur` event: the history popover can + * move focus to sibling rows and nested actions while the user is still + * interacting with the panel. Instead a document-level `pointerdown` + * listener commits only when the user actually clicks outside the input. + */ +function SessionRenameInput({ + initialValue, + onSubmit, + onCancel, +}: { + initialValue: string; + onSubmit: (value: string) => void; + onCancel: () => void; +}) { + const { t } = useT("chat"); + const [value, setValue] = useState(initialValue); + const inputRef = useRef(null); + // Hold the latest value + callback in refs so the mount-only effect's + // listener always sees fresh state without re-subscribing on every + // keystroke (which would briefly leave a window where pointerdown isn't + // observed). + const valueRef = useRef(value); + valueRef.current = value; + const onSubmitRef = useRef(onSubmit); + onSubmitRef.current = onSubmit; + + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + + const handlePointerDown = (e: PointerEvent) => { + const input = inputRef.current; + if (!input) return; + if (input.contains(e.target as Node)) return; + onSubmitRef.current(valueRef.current); + }; + // Capture phase — commit before outside-click handling can close the + // popover and unmount this component. + document.addEventListener("pointerdown", handlePointerDown, true); + return () => { + document.removeEventListener("pointerdown", handlePointerDown, true); + }; + }, []); + + return ( + $.session_history.row_rename_aria)} + onChange={(e) => setValue(e.target.value)} + onClick={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onKeyDown={(e) => { + // Keep editing keys inside the input instead of letting the row + // selection keyboard handler consume them. + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + onSubmit(value); + } else if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }} + className="w-full rounded-sm bg-background px-1 py-0.5 text-sm outline-none ring-1 ring-border focus-visible:ring-brand" + /> + ); +} + +function useFormatTimeAgo(): (dateStr: string) => string { + const { t } = useT("chat"); + return (dateStr: string) => { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return t(($) => $.session_history.time.just_now); + if (diffMins < 60) return t(($) => $.session_history.time.minutes, { count: diffMins }); + if (diffHours < 24) return t(($) => $.session_history.time.hours, { count: diffHours }); + if (diffDays < 7) return t(($) => $.session_history.time.days, { count: diffDays }); + return date.toLocaleDateString(); + }; +} + +// Three starter prompts shown on the empty state. Each is keyed into the +// chat namespace so labels translate per locale; the icon stays raw since +// emojis are locale-neutral. +const STARTER_KEYS: ("list_open" | "summarize_today" | "plan_next")[] = [ + "list_open", + "summarize_today", + "plan_next", +]; +const STARTER_ICONS: Record<(typeof STARTER_KEYS)[number], string> = { + list_open: "📋", + summarize_today: "📝", + plan_next: "💡", +}; + +function EmptyState({ + hasSessions, + agentName, + onPickPrompt, +}: { + hasSessions: boolean; + agentName?: string; + onPickPrompt: (text: string) => void; +}) { + const { t } = useT("chat"); + // First-time experience: the user has never started a chat in this + // workspace. Educate before suggesting actions — starter prompts + // presume the user already knows what chat is for. + if (!hasSessions) { + return ( +
+
+

+ {t(($) => $.empty_state.first_time_title)} +

+

+ {t(($) => $.empty_state.first_time_intro)}{" "} + + {t(($) => $.empty_state.first_time_pillars)} + + {t(($) => $.empty_state.first_time_pillars_suffix)} +

+

+ {t(($) => $.empty_state.first_time_actions)} +

+
+
+ ); + } + + // Returning user: starter prompts are the fastest path back to action. + return ( +
+
+

+ {agentName + ? t(($) => $.empty_state.returning_title_named, { name: agentName }) + : t(($) => $.empty_state.returning_title_default)} +

+

+ {t(($) => $.empty_state.returning_subtitle)} +

+
+
+ {STARTER_KEYS.map((key) => { + const text = t(($) => $.starter_prompts[key]); + return ( + + ); + })} +
); } 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 0000000000..01a53ddeb7 --- /dev/null +++ b/packages/views/chat/components/use-chat-resize.ts @@ -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, +) { + 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 = { + 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/locales/en/chat.json b/packages/views/locales/en/chat.json index a6678797ac..c10f08c0e9 100644 --- a/packages/views/locales/en/chat.json +++ b/packages/views/locales/en/chat.json @@ -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", diff --git a/packages/views/locales/ja/chat.json b/packages/views/locales/ja/chat.json index 0fb6471063..ef63d945b7 100644 --- a/packages/views/locales/ja/chat.json +++ b/packages/views/locales/ja/chat.json @@ -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": "エージェント", diff --git a/packages/views/locales/ko/chat.json b/packages/views/locales/ko/chat.json index c46d3d90ba..975786c552 100644 --- a/packages/views/locales/ko/chat.json +++ b/packages/views/locales/ko/chat.json @@ -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": "에이전트", diff --git a/packages/views/locales/zh-Hans/chat.json b/packages/views/locales/zh-Hans/chat.json index fdea80507b..ddf2f7fc98 100644 --- a/packages/views/locales/zh-Hans/chat.json +++ b/packages/views/locales/zh-Hans/chat.json @@ -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": "智能体",