diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index bc62577b..f5b9f4bd 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1119,6 +1119,8 @@ "Page menu for {{name}}": "Page menu for {{name}}", "Create subpage of {{name}}": "Create subpage of {{name}}", "AI chat": "AI chat", + "Minimize": "Minimize", + "Tokens used in this chat": "Tokens used in this chat", "AI agent": "AI agent", "AI agent is typing…": "AI agent is typing…", "Send": "Send", diff --git a/apps/client/src/components/layouts/global/aside.tsx b/apps/client/src/components/layouts/global/aside.tsx index b4026c32..6faf853a 100644 --- a/apps/client/src/components/layouts/global/aside.tsx +++ b/apps/client/src/components/layouts/global/aside.tsx @@ -9,7 +9,6 @@ import { TableOfContents } from "@/features/editor/components/table-of-contents/ import { useAtomValue } from "jotai"; import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx"; -import AiChatPanel from "@/features/ai-chat/components/ai-chat-panel.tsx"; import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx"; export default function Aside() { @@ -39,26 +38,11 @@ export default function Aside() { component = ; title = "Details"; break; - case "ai-chat": - // The AI chat panel renders its own header (title + new-chat + close) and - // manages its own scrolling, so it bypasses the shared Aside chrome below. - component = ; - title = "AI chat"; - break; default: component = null; title = null; } - // The AI chat panel owns the full aside area (its own header + layout). - if (tab === "ai-chat") { - return ( - - {component} - - ); - } - return ( {component && ( diff --git a/apps/client/src/components/layouts/global/global-app-shell.tsx b/apps/client/src/components/layouts/global/global-app-shell.tsx index 34f21097..d373f5e7 100644 --- a/apps/client/src/components/layouts/global/global-app-shell.tsx +++ b/apps/client/src/components/layouts/global/global-app-shell.tsx @@ -13,6 +13,7 @@ import { import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx"; import { AppHeader } from "@/components/layouts/global/app-header.tsx"; import Aside from "@/components/layouts/global/aside.tsx"; +import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.tsx"; import classes from "./app-shell.module.css"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx"; @@ -153,6 +154,9 @@ export default function GlobalAppShell({ )} + {/* Floating AI chat window. Mounted once globally; it is position: fixed + and self-hides when closed, so its place in the tree is not critical. */} + ); } diff --git a/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts b/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts index d143012f..54843450 100644 --- a/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts +++ b/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts @@ -9,3 +9,6 @@ import { atom } from "jotai"; // which mis-resolves the jotai useAtom overload to the read-only signature // under this TS/jotai version (the setter would type as `never`). export const activeAiChatIdAtom = atom(null as string | null); + +// Whether the floating AI chat window is open. Non-persistent (resets per session). +export const aiChatWindowOpenAtom = atom(false); diff --git a/apps/client/src/features/ai-chat/components/ai-chat-panel.tsx b/apps/client/src/features/ai-chat/components/ai-chat-panel.tsx deleted file mode 100644 index b1a10e06..00000000 --- a/apps/client/src/features/ai-chat/components/ai-chat-panel.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { - ActionIcon, - Box, - Collapse, - Divider, - Group, - Loader, - Text, - Title, - Tooltip, -} from "@mantine/core"; -import { IconChevronDown, IconPlus, IconX } from "@tabler/icons-react"; -import { useDisclosure } from "@mantine/hooks"; -import { useAtom } from "jotai"; -import { useParams } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { useQueryClient } from "@tanstack/react-query"; -import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; -import { activeAiChatIdAtom } 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, - useAiChatMessagesQuery, - useAiChatsQuery, -} from "@/features/ai-chat/queries/ai-chat-query.ts"; -import ConversationList from "@/features/ai-chat/components/conversation-list.tsx"; -import ChatThread from "@/features/ai-chat/components/chat-thread.tsx"; - -/** - * Right-aside AI chat container (§7.1): a header (title + new-chat + close), a - * collapsible conversation switcher, the active chat thread (message list + - * input), all driven by `useChat` inside ChatThread. - */ -export default function AiChatPanel() { - const { t } = useTranslation(); - const queryClient = useQueryClient(); - const [, setAsideState] = useAtom(asideStateAtom); - const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom); - const [listOpen, { toggle: toggleList, close: closeList }] = - useDisclosure(false); - - // Track whether we are awaiting the id of a just-created (new) chat, so we can - // adopt it once the chat list refreshes after the first turn finishes. - const adoptNewChat = useRef(false); - - const { data: chats } = useAiChatsQuery(); - const { data: messageRows, isLoading: messagesLoading } = - useAiChatMessagesQuery(activeChatId ?? undefined); - - // The page the user is currently viewing, derived from the route (same source - // the breadcrumb uses). On a non-page route `pageSlug` is undefined, so 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 { pageSlug } = useParams(); - const { data: openPageData } = usePageQuery({ - pageId: extractPageSlugId(pageSlug), - }); - const openPage = openPageData - ? { id: openPageData.id, title: openPageData.title } - : null; - - const closeAside = (): void => - setAsideState((s) => ({ ...s, isAsideOpen: false })); - - const startNewChat = (): void => { - setActiveChatId(null); - closeList(); - }; - - const selectChat = (chatId: string): void => { - setActiveChatId(chatId); - closeList(); - }; - - // After a turn finishes, refresh the chat list. For a brand-new chat (no id - // yet), the server has just created the row; adopt the newest chat id so the - // thread switches from "new" to the persisted chat (and loads its history on - // later opens). - const onTurnFinished = useCallback(() => { - if (activeChatId === null) adoptNewChat.current = true; - queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY }); - }, [activeChatId, queryClient]); - - // When awaiting a new chat's id, adopt the most-recent chat (the list is - // ordered newest-first) once it appears. - useEffect(() => { - if (!adoptNewChat.current) return; - const newest = chats?.items?.[0]; - if (newest) { - adoptNewChat.current = false; - setActiveChatId(newest.id); - } - }, [chats, setActiveChatId]); - - // The thread is remounted when the active chat changes so initial messages - // re-seed. For a new chat we key on "new"; adopting the id remounts the thread - // with the persisted history loaded. - const threadKey = activeChatId ?? "new"; - const waitingForHistory = activeChatId !== null && messagesLoading; - - return ( - - - - {t("AI chat")} - - - - - - - - - - - - - - - - - - - - {t("Chat history")} - - - - - - - - - - {waitingForHistory ? ( - - - - ) : ( - - )} - - - ); -} diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.module.css b/apps/client/src/features/ai-chat/components/ai-chat-window.module.css new file mode 100644 index 00000000..af133b8d --- /dev/null +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.module.css @@ -0,0 +1,157 @@ +/* Floating AI chat window shell. Ported from the GitmostAgent.jsx design. + Dynamic values (left/top/width/height) stay inline on the element; only the + static chrome lives here. */ + +.window { + /* position: fixed + left/top/width/height are applied inline so the window + floats over the whole viewport. z-index sits above page content and the + app shell but BELOW Mantine overlays (modals=200, menus=300, + notifications=400) so the rename input, kebab menu and delete-confirm + modal still render above the window. */ + position: fixed; + z-index: 105; + background: light-dark(#fff, var(--mantine-color-dark-7)); + border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + border-radius: 14px; + box-shadow: + 0 10px 24px rgba(0, 0, 0, 0.13), + 0 30px 64px rgba(0, 0, 0, 0.17); + overflow: hidden; + resize: both; + display: flex; + flex-direction: column; + min-width: 300px; + min-height: 400px; + max-width: 560px; + max-height: 880px; + font-size: 12px; + color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0)); +} + +/* Hide the native resizer; we draw our own affordance icon in the corner. */ +.window::-webkit-resizer { + background: transparent; +} + +/* When minimized the window collapses to the header only: auto height, no + resize. Width/height inline values are overridden. */ +.minimized { + height: auto !important; + min-height: 0 !important; + resize: none; +} + +/* Body wrapper (history + chat thread). Always rendered so ChatThread stays + mounted; hidden (not unmounted) when minimized so an in-flight stream keeps + running and the window collapses to just the header. */ +.content { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.minimized .content { + display: none; +} + +.dragBar { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 11px; + border-bottom: 1px solid light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); + background: light-dark(#fcfcfd, var(--mantine-color-dark-6)); + flex: none; + cursor: grab; + user-select: none; +} + +.title { + font-size: 13px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.headerBtn { + width: 24px; + height: 24px; + border: none; + background: transparent; + border-radius: 6px; + color: var(--mantine-color-dimmed); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; +} + +.headerBtn:hover { + background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); +} + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--mantine-color-dimmed); + background: light-dark(#f6f7f8, var(--mantine-color-dark-6)); + border: 1px solid light-dark(#eceef0, var(--mantine-color-dark-4)); + border-radius: 6px; + padding: 3px 9px; +} + +.historySection { + padding: 6px 8px; + border-bottom: 1px solid light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); + flex: none; +} + +.historyHeader { + display: flex; + align-items: center; + gap: 5px; + padding: 4px 6px; + border-radius: 6px; + cursor: pointer; + color: var(--mantine-color-dimmed); + font-weight: 500; +} + +.newChatBtn { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 7px; + border-radius: 6px; + border: none; + background: transparent; + color: var(--mantine-color-dimmed); + font-weight: 500; + cursor: pointer; + font-size: 12px; +} + +.newChatBtn:hover { + background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); +} + +.body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 13px 14px 12px; + overflow: hidden; +} + +.resizeHandle { + position: absolute; + right: 3px; + bottom: 3px; + color: light-dark(#ced4da, var(--mantine-color-dark-3)); + pointer-events: none; + display: flex; +} diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx new file mode 100644 index 00000000..1cb2087f --- /dev/null +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx @@ -0,0 +1,402 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Group, Loader, Tooltip } from "@mantine/core"; +import { + IconArrowsDiagonal, + IconChevronDown, + IconGripVertical, + IconMinus, + IconPlus, + IconX, +} from "@tabler/icons-react"; +import { useAtom } from "jotai"; +import { useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { + activeAiChatIdAtom, + aiChatWindowOpenAtom, +} 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, + useAiChatMessagesQuery, + useAiChatsQuery, +} from "@/features/ai-chat/queries/ai-chat-query.ts"; +import ConversationList from "@/features/ai-chat/components/conversation-list.tsx"; +import ChatThread from "@/features/ai-chat/components/chat-thread.tsx"; +import classes from "@/features/ai-chat/components/ai-chat-window.module.css"; + +// Default window geometry (from the GitmostAgent.jsx design). +const DEFAULT_WIDTH = 362; +const DEFAULT_HEIGHT = 602; +// 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 = DEFAULT_WIDTH; + 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, adopt-new-chat, 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 } = useTranslation(); + const queryClient = useQueryClient(); + const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom); + const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom); + + // History section starts collapsed (matches the former panel's behavior). + const [historyOpen, setHistoryOpen] = useState(false); + const [minimized, setMinimized] = useState(false); + + const winRef = useRef(null); + // Live window geometry (position + size); initialized lazily on first open so + // it is anchored to the current viewport (top-right corner). Kept in state so + // a user resize survives close/reopen and can be re-clamped to the viewport. + const [geom, setGeom] = useState<{ + left: number; + top: number; + width: number; + height: number; + } | null>(null); + + // Track whether we are awaiting the id of a just-created (new) chat, so we + // can adopt it once the chat list refreshes after the first turn finishes. + const adoptNewChat = useRef(false); + + const { data: chats } = useAiChatsQuery(); + const { data: messageRows, isLoading: messagesLoading } = + useAiChatMessagesQuery(activeChatId ?? undefined); + + // The page the user is currently viewing, derived from the route (same + // source the breadcrumb uses). On a non-page route `pageSlug` is undefined, + // so 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 { pageSlug } = useParams(); + const { data: openPageData } = usePageQuery({ + pageId: extractPageSlugId(pageSlug), + }); + const openPage = openPageData + ? { id: openPageData.id, title: openPageData.title } + : null; + + const startNewChat = useCallback((): void => { + setActiveChatId(null); + setHistoryOpen(false); + }, [setActiveChatId]); + + const selectChat = useCallback( + (chatId: string): void => { + setActiveChatId(chatId); + setHistoryOpen(false); + }, + [setActiveChatId], + ); + + // After a turn finishes, refresh the chat list. For a brand-new chat (no id + // yet), the server has just created the row; adopt the newest chat id so the + // thread switches from "new" to the persisted chat (and loads its history on + // later opens). + const onTurnFinished = useCallback(() => { + if (activeChatId === null) adoptNewChat.current = true; + queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY }); + }, [activeChatId, queryClient]); + + // When awaiting a new chat's id, adopt the most-recent chat (the list is + // ordered newest-first) once it appears. + useEffect(() => { + if (!adoptNewChat.current) return; + const newest = chats?.items?.[0]; + if (newest) { + adoptNewChat.current = false; + setActiveChatId(newest.id); + } + }, [chats, setActiveChatId]); + + // The thread is remounted when the active chat changes so initial messages + // re-seed. For a new chat we key on "new"; adopting the id remounts the + // thread with the persisted history loaded. + const threadKey = activeChatId ?? "new"; + const waitingForHistory = activeChatId !== null && messagesLoading; + + // Sum of persisted token usage for the active chat. NOTE: this reflects the + // PERSISTED rows for the active chat (updates on chat open/switch); it does + // not tick live mid-stream — acceptable for v1. + const totalTokens = useMemo(() => { + if (!activeChatId || !messageRows) return 0; + return messageRows.reduce((sum, row) => { + const usage = row.metadata?.usage; + if (!usage) return sum; + const rowTokens = + usage.totalTokens ?? + (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0); + return sum + rowTokens; + }, 0); + }, [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())); + }, [windowOpen]); + + // 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; + 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]); + + 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 = (): void => { + document.removeEventListener("mousemove", move); + document.removeEventListener("mouseup", up); + document.body.style.userSelect = ""; + 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 */} +
+ + {t("AI chat")} + +
+ {totalTokens > 0 && ( + + {formatTokens(totalTokens)} + + )} +
+ +
+ + +
+
+ + {/* 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)} + > + + {t("Chat history")} +
+ +
+ {historyOpen && ( +
+ +
+ )} +
+ + {/* body: active chat thread */} +
+ {waitingForHistory ? ( + + + + ) : ( + + )} +
+
+ + {/* resize affordance icon (drawn manually; native resizer is hidden) */} + {!minimized && ( + + + + )} +
+ ); +} diff --git a/apps/client/src/features/ai-chat/types/ai-chat.types.ts b/apps/client/src/features/ai-chat/types/ai-chat.types.ts index fd5e59f8..f65890ec 100644 --- a/apps/client/src/features/ai-chat/types/ai-chat.types.ts +++ b/apps/client/src/features/ai-chat/types/ai-chat.types.ts @@ -26,7 +26,16 @@ export interface IAiChatMessageRow { role: "user" | "assistant" | string; content: string | null; toolCalls?: unknown; - metadata?: { parts?: UIMessage["parts"] } | null; + metadata?: { + parts?: UIMessage["parts"]; + // AI SDK v6 `totalUsage` persisted on assistant rows. Used to sum the token + // count shown in the floating window's header badge. + usage?: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + }; + } | null; createdAt: string; } diff --git a/apps/client/src/features/page-history/components/history-item.tsx b/apps/client/src/features/page-history/components/history-item.tsx index a8f88a89..e2f02110 100644 --- a/apps/client/src/features/page-history/components/history-item.tsx +++ b/apps/client/src/features/page-history/components/history-item.tsx @@ -8,8 +8,10 @@ import { IPageHistory } from "@/features/page-history/types/page.types"; import { memo, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useSetAtom } from "jotai"; -import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; -import { activeAiChatIdAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts"; +import { + activeAiChatIdAtom, + aiChatWindowOpenAtom, +} from "@/features/ai-chat/atoms/ai-chat-atom.ts"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; const MAX_VISIBLE_AVATARS = 5; @@ -27,9 +29,9 @@ interface HistoryItemProps { * Badge marking a version written by the AI agent (provenance C3 / §7.4). It is * ADDITIVE — shown next to the human author, never replacing them. When the * version carries an `aiChatId`, clicking the badge deep-links into that chat: - * it sets the active-chat atom, opens the AI-chat aside tab, and closes the - * history modal. The click is contained (stopPropagation) so it does not also - * trigger the row's version-select. + * it sets the active-chat atom, opens the floating AI-chat window, and closes + * the history modal. The click is contained (stopPropagation) so it does not + * also trigger the row's version-select. */ function AiAgentBadge({ authorName, @@ -39,7 +41,7 @@ function AiAgentBadge({ aiChatId?: string | null; }) { const { t } = useTranslation(); - const setAsideState = useSetAtom(asideStateAtom); + const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom); const setActiveChatId = useSetAtom(activeAiChatIdAtom); const setHistoryModalOpen = useSetAtom(historyAtoms); @@ -52,10 +54,10 @@ function AiAgentBadge({ event.stopPropagation(); if (!aiChatId) return; setActiveChatId(aiChatId); - setAsideState({ tab: "ai-chat", isAsideOpen: true }); + setAiChatWindowOpen(true); setHistoryModalOpen(false); }, - [aiChatId, setActiveChatId, setAsideState, setHistoryModalOpen], + [aiChatId, setActiveChatId, setAiChatWindowOpen, setHistoryModalOpen], ); const badge = ( diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index fd0d850c..c802bc44 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -20,7 +20,8 @@ import { } from "@tabler/icons-react"; import React, { useEffect, useRef, useState } from "react"; import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx"; -import { useAtom, useAtomValue } from "jotai"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; import { useDisclosure, useHotkeys } from "@mantine/hooks"; import { useClipboard } from "@/hooks/use-clipboard"; @@ -64,7 +65,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { const { t } = useTranslation(); const commentsTriggerProps = useAsideTriggerProps("comments"); const tocTriggerProps = useAsideTriggerProps("toc"); - const aiChatTriggerProps = useAsideTriggerProps("ai-chat"); + const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom); const { pageSlug } = useParams(); const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug), @@ -137,7 +138,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { variant="subtle" color="dark" aria-label={t("AI chat")} - {...aiChatTriggerProps} + onClick={() => setAiChatWindowOpen((v) => !v)} >