import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { Group, Loader, Tooltip } from "@mantine/core"; import { IconArrowsDiagonal, IconCheck, IconChevronDown, IconCopy, IconGripVertical, IconMinus, IconPlus, IconX, } from "@tabler/icons-react"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useMatch } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { activeAiChatIdAtom, aiChatWindowOpenAtom, aiChatWindowGeomAtom, aiChatDraftAtom, selectedAiRoleIdAtom, } from "@/features/ai-chat/atoms/ai-chat-atom.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { extractPageSlugId } from "@/lib"; import { AI_CHATS_RQ_KEY, AI_CHAT_MESSAGES_RQ_KEY, useAiChatMessagesQuery, useAiChatRunQuery, useAiChatsQuery, useAiRolesQuery, } from "@/features/ai-chat/queries/ai-chat-query.ts"; import { shouldObserveRun } from "@/features/ai-chat/utils/run-polling.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom"; import ConversationList from "@/features/ai-chat/components/conversation-list.tsx"; import ChatThread from "@/features/ai-chat/components/chat-thread.tsx"; import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts"; import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts"; import { shouldCollapseOnOutsidePointer, isHeaderClick, } from "@/features/ai-chat/utils/collapse-helpers.ts"; import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts"; import { useClipboard } from "@/hooks/use-clipboard"; import { notifications } from "@mantine/notifications"; import classes from "@/features/ai-chat/components/ai-chat-window.module.css"; // Default window dimensions (wider default per user request); both are // clamped to the viewport in computeInitialGeom(). const DEFAULT_WIDTH = 540; const DEFAULT_HEIGHT = 680; // CSS-enforced minimum window size (ai-chat-window.module.css). The geometry // math must respect these so the real box is clamped within the viewport. const MIN_WIDTH = 300; const MIN_HEIGHT = 400; // Margin kept between the window and the viewport edges while dragging. const EDGE_MARGIN = 8; /** Compact token formatter: 1.2M / 3.4k / 950. */ function formatTokens(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; return String(n); } // Compute the initial top-right placement at the default size, fitted to the // current viewport. Reads `window` only when called (inside an effect). function computeInitialGeom() { const width = Math.max( MIN_WIDTH, Math.min(DEFAULT_WIDTH, window.innerWidth - 2 * EDGE_MARGIN), ); const height = Math.max( MIN_HEIGHT, Math.min(DEFAULT_HEIGHT, window.innerHeight - 2 * EDGE_MARGIN), ); const left = Math.max(EDGE_MARGIN, window.innerWidth - width - 24); const maxTop = Math.max( EDGE_MARGIN, window.innerHeight - height - EDGE_MARGIN, ); const top = Math.min(60, maxTop); return { left, top, width, height }; } // Clamp a geometry so the window stays within the current viewport. function clampGeom(g: { left: number; top: number; width: number; height: number; }) { const effWidth = Math.max(g.width, MIN_WIDTH); const effHeight = Math.max(g.height, MIN_HEIGHT); const maxLeft = Math.max( EDGE_MARGIN, window.innerWidth - effWidth - EDGE_MARGIN, ); const maxTop = Math.max( EDGE_MARGIN, window.innerHeight - effHeight - EDGE_MARGIN, ); return { ...g, left: Math.min(Math.max(EDGE_MARGIN, g.left), maxLeft), top: Math.min(Math.max(EDGE_MARGIN, g.top), maxTop), }; } /** * Floating, draggable, resizable, minimizable AI chat window. Replaces the * former right-aside `AiChatPanel`: it owns ALL chat orchestration (active * chat, new chat, in-place id adoption from streamed metadata, open-page * context, token sum) and wraps the * reused inner components (ConversationList + ChatThread) in window chrome * ported from the GitmostAgent.jsx design. */ export default function AiChatWindow() { const { t, i18n } = useTranslation(); const clipboard = useClipboard({ timeout: 500 }); const queryClient = useQueryClient(); const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom); const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom); const setDraft = useSetAtom(aiChatDraftAtom); // The role chosen for the next new chat (null = universal assistant). const [selectedRoleId, setSelectedRoleId] = useAtom(selectedAiRoleIdAtom); // History section starts collapsed (matches the former panel's behavior). const [historyOpen, setHistoryOpen] = useState(false); const [minimized, setMinimized] = useState(false); // Mirror of `minimized` for handlers wrapped in useCallback([]) (startDrag), // which would otherwise close over a stale value. Kept in sync below. const minimizedRef = useRef(minimized); minimizedRef.current = minimized; const winRef = useRef(null); // Live window geometry (position + size); persisted to localStorage so a // drag/resize survives a full page reload (and close/reopen). `null` means // "never placed yet" — the layout effect below then computes an initial // top-right placement anchored to the current viewport, and on restore it is // re-clamped to the viewport (so a placement saved on a larger screen is not // left partly off-screen). const [geom, setGeom] = useAtom(aiChatWindowGeomAtom); const { data: chats } = useAiChatsQuery(); // Roles for the new-chat picker (any member may list them). Only fetched while // the window is open. const { data: roles } = useAiRolesQuery(windowOpen); // The new-chat picker only offers ENABLED roles. The list endpoint returns // all live roles (so the admin settings section can manage disabled ones), so // we filter to `enabled` here, client-side, for the composer picker only. const enabledRoles = useMemo( () => (roles ?? []).filter((r) => r.enabled === true), [roles], ); const { data: messageRows, isLoading: messagesLoading } = useAiChatMessagesQuery(activeChatId ?? undefined); // #184 reconnect-and-live-follow. Whether detached agent runs are enabled for // this workspace. The reconnect endpoint itself is NOT flag-gated server-side // (it is only owner-gated and returns `{ run: null }` when the chat has no // run); but when the feature is off no runs are ever created, so polling it // would always come back empty — we gate it off here to avoid pointless polls. const workspace = useAtomValue(workspaceAtom); const autonomousRunsEnabled = workspace?.settings?.ai?.autonomousRuns === true; // Whether THIS tab is the one actively streaming the open chat's run locally // (it started the run here and holds the SSE). Reported up from ChatThread. We // are the STREAMER while true and a passive OBSERVER while false — the basis of // the observer-vs-streamer detection. Reset to false by the fresh ChatThread's // mount effect on every chat switch. const [localStreaming, setLocalStreaming] = useState(false); const onStreamingChange = useCallback((streaming: boolean) => { setLocalStreaming(streaming); }, []); // Poll the latest run of the open chat ONLY when we are a passive observer: // feature on, a chat is open, and we are NOT the local streamer (the streamer // already has the live SSE — polling/merging too would double-render). The // query's own status-keyed refetchInterval stops once the run is terminal. const { data: runData } = useAiChatRunQuery( activeChatId ?? undefined, autonomousRunsEnabled && !localStreaming, ); const run = runData?.run ?? null; // The run's incrementally-persisted assistant message to merge into the thread, // but only while we are an observer (never when we are the streamer — guards // against a stale poll fighting the live stream). Includes a terminal run so the // final persisted output is shown on reopen. const observedRow = shouldObserveRun(run, localStreaming) ? (runData?.message ?? null) : null; // When the observed run reaches a terminal status, do a final messages refetch // so the persisted final state (token/context badge, export source) is shown, // then the query's refetchInterval has already stopped polling. Deduped per run // id so it fires exactly once per run, not on every subsequent poll-less render. const finalizedRunIdRef = useRef(null); useEffect(() => { if (!run || !activeChatId) return; if (run.status === "pending" || run.status === "running") { // Active again (a new run) — re-arm so its terminal transition fires once. finalizedRunIdRef.current = null; return; } if (finalizedRunIdRef.current === run.id) return; finalizedRunIdRef.current = run.id; queryClient.invalidateQueries({ queryKey: AI_CHAT_MESSAGES_RQ_KEY(activeChatId), }); }, [run, activeChatId, queryClient]); // The page the user is currently viewing. AiChatWindow lives in a pathless // parent layout route, so useParams() can't see :pageSlug. Match the full // pathname against the authenticated page route instead so "the current page" // resolves regardless of where this component is mounted. On a non-page route // the match is null, so `pageSlug` is undefined, the query is disabled and // `openPage` is null. This is passed to the chat thread as context so the // agent knows what "this page"/"the current page" refers to; the agent still // reads/writes via its CASL-enforced page tools using the id. const pageRouteMatch = useMatch("/s/:spaceSlug/p/:pageSlug"); const pageSlug = pageRouteMatch?.params?.pageSlug; const { data: openPageData } = usePageQuery({ pageId: extractPageSlugId(pageSlug), }); const openPage = openPageData ? { id: openPageData.id, title: openPageData.title } : null; // The AI-chat thread-identity lifecycle (mount key, both new-chat id adoption // paths, the history-loaded latch, the render-phase reconciler) lives in this // hook. See adopt-chat-id.ts for the canonical #137 two-tab race explanation. // The invalidate closures are passed inline: `onTurnFinished` is read live by // useChat's onFinish (never in an effect dep array), so their identity does not // matter — no memoization ceremony needed. const { threadKey, waitingForHistory, startFreshThread, onTurnFinished, onServerChatId, cancelPendingAdoption, } = useChatSession({ activeChatId, setActiveChatId, chats, messagesLoading, onInvalidateChatList: () => queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY }), onInvalidateChatMessages: (id) => queryClient.invalidateQueries({ queryKey: AI_CHAT_MESSAGES_RQ_KEY(id) }), }); // startNewChat/selectChat set the public atom; the hook's render-phase // reconciler handles the remount when activeChatId actually CHANGES. But // pressing "New chat" while already in a new chat leaves activeChatId === null // (a no-op for the atom), so the reconciler never fires — explicitly disarm any // armed error-path fallback here so a late refetch can't yank the user into a // just-failed chat after they chose a fresh one. const startNewChat = useCallback((): void => { cancelPendingAdoption(); // Force a fresh, empty thread UNCONDITIONALLY (#161). Pressing "New chat" // while a brand-new chat's first turn is still streaming leaves activeChatId // null (the real id is adopted only at turn end), so setActiveChatId(null) // alone is a no-op and the reconciler never remounts — the chat/stream/history // would persist and only the role badge would drop. This always remounts the // thread into a clean new chat. startFreshThread(); setActiveChatId(null); setHistoryOpen(false); setDraft(""); // Default the picker back to "Universal assistant" for the fresh chat. setSelectedRoleId(null); }, [ cancelPendingAdoption, startFreshThread, setActiveChatId, setDraft, setSelectedRoleId, ]); const selectChat = useCallback( (chatId: string): void => { cancelPendingAdoption(); setActiveChatId(chatId); setHistoryOpen(false); setDraft(""); // Reset the card-picked role so a stale pick can't leak into the existing // chat's header/assistant-name (which prefers the chat's persisted role). setSelectedRoleId(null); }, [cancelPendingAdoption, setActiveChatId, setDraft, setSelectedRoleId], ); // The active chat object (for its title) and an export gate. The export is now // SERVER-sourced (the DB is the single source of truth — #183): the assistant // row is persisted upfront + per step, so even a brand-new chat whose first // turn is streaming/interrupted has a server row to render. Enable the button // whenever a persisted chat is active (`activeChatId` is set). For a BRAND-NEW // chat that id is adopted EARLY — at the stream's `start` chunk via // onServerChatId (#174) — so the Copy button is available during the first // turn's stream, not only after it terminates. const activeChat = useMemo( () => chats?.items?.find((c) => c.id === activeChatId) ?? null, [chats, activeChatId], ); const canExport = !!activeChatId; // The role to display in the header and as the assistant's name. Prefer the // persisted role of an existing chat (chat-list JOIN); fall back to the role // picked via a card click for a brand-new or just-adopted chat. selectChat // resets selectedRoleId, so this fallback never leaks into an unrelated chat. const currentRole = useMemo<{ name: string; emoji: string | null; } | null>(() => { if (activeChat?.roleName) { return { name: activeChat.roleName, emoji: activeChat.roleEmoji ?? null }; } const picked = enabledRoles.find((r) => r.id === selectedRoleId); return picked ? { name: picked.name, emoji: picked.emoji } : null; }, [activeChat, enabledRoles, selectedRoleId]); // Fetch the server-rendered Markdown export and copy it to the clipboard. The // server is the single source of truth (#183): it renders the transcript from // the persisted rows — including an interrupted turn's in-progress row — so the // export is identical whether the chat is freshly streaming, just switched to, // or reloaded. The `lang` of the active i18n drives the few localized labels. const handleCopy = useCallback(async () => { if (!activeChatId) return; try { const markdown = await exportAiChat(activeChatId, i18n.language); clipboard.copy(markdown); notifications.show({ message: t("Copied") }); } catch { notifications.show({ message: t("Failed to export chat"), color: "red" }); } }, [activeChatId, clipboard, t, i18n.language]); // Current context size for the active chat: how much the conversation now // occupies in the model's context window — NOT the cumulative tokens spent. // We read the most recent assistant row that carries a context figure: // `contextTokens` (final-step input+output) for chats recorded after this // shipped; older rows fall back to that turn's `usage` total. NOTE: reflects // PERSISTED rows (updates on chat open/switch); it does not tick live // mid-stream — acceptable for v1. // // The denominator `maxContextTokens` (the model's configured max window) is // derived in the SAME backward scan: it is stamped alongside `contextTokens` // on a completed turn, but the numerator and denominator are taken from the // most recent row carrying EACH value independently — they may land on // different rows (e.g. a fresh error row can carry contextTokens but not // maxContextTokens), so we keep scanning for whichever is still unset. 0 when // no row has it (older rows, or no admin-configured limit) — the badge then // shows just the current size with no denominator. const { contextTokens, maxContextTokens } = useMemo( () => selectContextBadge(activeChatId ? messageRows : undefined), [activeChatId, messageRows], ); // On (re)open, settle the geometry before paint (useLayoutEffect → no // first-frame jump): compute an initial top-right placement the first time, // and re-clamp an existing geometry to the current viewport on later opens // (so a stale placement is not left partly off-screen after a viewport // shrink). setGeom here does not loop — windowOpen is unchanged by it. useLayoutEffect(() => { if (!windowOpen) return; setGeom((prev) => (prev ? clampGeom(prev) : computeInitialGeom())); // Always show the window expanded on (re)open: a collapsed state from a // previous open session must not stick. Runs before paint so the first // frame is already expanded. The composer's autofocus is a focus INSIDE the // window (not an outside mousedown), so it cannot self-collapse the window. setMinimized(false); }, [windowOpen]); // Auto-collapse the window into its header as soon as the user interacts with // anything outside it (clicks the page/editor). Armed ONLY while the window is // open and expanded, so it never fires repeatedly and never collapses on the // open→reset transition. Capture phase so a page handler's stopPropagation in // the bubble phase can't hide the event from us; the in-window/portal guards // (shouldCollapseOnOutsidePointer) prevent false collapses from clicks inside // the window or inside Mantine portals (kebab menu, delete-confirm modal). useEffect(() => { if (!windowOpen || minimized) return; const onPointerDown = (e: MouseEvent): void => { if (shouldCollapseOnOutsidePointer(e.target, winRef.current)) { setMinimized(true); } }; document.addEventListener("mousedown", onPointerDown, true); return () => document.removeEventListener("mousedown", onPointerDown, true); }, [windowOpen, minimized]); // Persist the user's resize into state so it survives close/reopen. Skipped // while minimized so the collapsed (auto) height is never captured. The // equality guard avoids an update loop. useEffect(() => { if (!windowOpen || minimized) return; const el = winRef.current; // `geom` is in the deps so this re-runs once geometry is settled and the // window is actually rendered (on the first open `geom` is still null on the // render that flips windowOpen, so winRef.current is null then — without the // geom dep the observer would never attach and resizes would not persist). if (!el) return; const ro = new ResizeObserver(() => { const width = el.offsetWidth; const height = el.offsetHeight; setGeom((prev) => { if (!prev || (prev.width === width && prev.height === height)) return prev; return { ...prev, width, height }; }); }); ro.observe(el); return () => ro.disconnect(); }, [windowOpen, minimized, geom !== null]); const startDrag = useCallback((e: React.MouseEvent): void => { // Ignore drags that originate on a button (minimize/close/new chat). if ((e.target as HTMLElement).closest("button")) return; const el = winRef.current; if (!el) return; const sx = e.clientX; const sy = e.clientY; const ol = parseFloat(el.style.left) || 0; const ot = parseFloat(el.style.top) || 0; const move = (ev: MouseEvent): void => { let nl = ol + (ev.clientX - sx); let nt = ot + (ev.clientY - sy); // Clamp to the viewport (not the parent — the window is mounted globally // with position: fixed) with an 8px margin. nl = Math.max( EDGE_MARGIN, Math.min(nl, window.innerWidth - el.offsetWidth - EDGE_MARGIN), ); nt = Math.max( EDGE_MARGIN, Math.min(nt, window.innerHeight - el.offsetHeight - EDGE_MARGIN), ); el.style.left = `${nl}px`; el.style.top = `${nt}px`; }; const up = (ev: MouseEvent): void => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); document.body.style.userSelect = ""; // Treat a near-zero-movement press as a click (not a drag). When the // window is minimized, a header click expands it; nothing to persist // because the position did not change. minimizedRef avoids the stale // `minimized` captured by useCallback([]). if ( minimizedRef.current && isHeaderClick(sx, sy, ev.clientX, ev.clientY) ) { setMinimized(false); return; } const el2 = winRef.current; // Persist the final position back into state (preserving the size) so // re-renders keep it. if (el2) { setGeom((prev) => prev ? { ...prev, left: parseFloat(el2.style.left) || 0, top: parseFloat(el2.style.top) || 0, } : prev, ); } }; document.addEventListener("mousemove", move); document.addEventListener("mouseup", up); document.body.style.userSelect = "none"; e.preventDefault(); }, []); // Just toggle the flag. The `.minimized` CSS handles the collapsed height and // disables resize, and `.minimized .content` hides the body while keeping // ChatThread mounted (so an in-flight stream is not aborted). const toggleMinimize = useCallback((): void => { setMinimized((m) => !m); }, []); if (!windowOpen || !geom) return null; return (
{/* drag bar / header. Mouse users expand a minimized window by clicking anywhere on the bar (the click-vs-drag logic in startDrag, which excludes the buttons). The keyboard/screen-reader Expand affordance lives on the title element below — NOT on this container — so we never nest the Minimize/Close )}
{/* Body is ALWAYS rendered (just hidden via .minimized .content CSS when minimized) so ChatThread — and its useChat store/AbortController — stays mounted and an in-flight stream is never aborted. */}
{/* history */}
setHistoryOpen((o) => !o)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setHistoryOpen((o) => !o); } }} > {t("Chat history")}
{historyOpen && (
)}
{/* The role picker for a NEW chat is rendered as the chat's empty-state (colored role cards centered in the empty window) by ChatThread itself — clicking a card starts the chat with that role. Once the chat exists, its role is fixed and shown as a header badge instead. */} {/* body: active chat thread */}
{waitingForHistory ? ( ) : ( setSelectedRoleId(role.id)} assistantName={currentRole?.name} onTurnFinished={onTurnFinished} onServerChatId={onServerChatId} // #184: live-follow a still-running run when we reopened the chat as // a passive observer; null when there is nothing to observe or this // tab is the streamer. onStreamingChange lets the window stop polling // while we are the streamer. observedRow={observedRow} onStreamingChange={onStreamingChange} /> )}
{/* resize affordance icon (drawn manually; native resizer is hidden) */} {!minimized && ( )} ); }