feat(ai-chat): convert AI chat from aside panel to floating window
Replace the docked right-aside AI chat with a floating, draggable, resizable, minimizable window per the GitmostAgent design. The "AI chat" entry points (page header menu, page-history item) now open the window instead of the aside tab. - Add ai-chat-window.tsx + ai-chat-window.module.css: fixed-position window with viewport-clamped drag, CSS resize, minimize (hides body via CSS so ChatThread/useChat stays mounted and streaming is not aborted), and geometry kept in state (survives close/reopen, re-clamped on open via useLayoutEffect, size tracked with a ResizeObserver). - Reuse ChatThread, ConversationList and the transcript components unchanged; move all orchestration (active chat, adopt-new-chat, openPage, queries) into the window. - Header shows a tokens-only badge: sum of persisted metadata.usage for the active chat (no cost/context-total data available), hidden at 0. - Add aiChatWindowOpenAtom; mount the window once in global-app-shell. - Remove the aside "ai-chat" tab handling and delete ai-chat-panel.tsx. - Type IAiChatMessageRow.metadata.usage; add "Minimize" and "Tokens used in this chat" i18n keys.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 = <PageDetailsAside />;
|
||||
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 = <AiChatPanel />;
|
||||
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 (
|
||||
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{component}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{component && (
|
||||
|
||||
@@ -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({
|
||||
</AppShell.Aside>
|
||||
)}
|
||||
</AppShell>
|
||||
{/* 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. */}
|
||||
<AiChatWindow />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<boolean>(false);
|
||||
|
||||
@@ -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 (
|
||||
<Box style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<Group justify="space-between" wrap="nowrap" mb="xs">
|
||||
<Title order={2} size="h6" fw={500}>
|
||||
{t("AI chat")}
|
||||
</Title>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Tooltip label={t("New chat")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={startNewChat}
|
||||
aria-label={t("New chat")}
|
||||
>
|
||||
<IconPlus size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Close")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={closeAside}
|
||||
aria-label={t("Close")}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
onClick={toggleList}
|
||||
style={{ cursor: "pointer" }}
|
||||
mb={listOpen ? "xs" : 0}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconChevronDown
|
||||
size={14}
|
||||
style={{
|
||||
transform: listOpen ? "none" : "rotate(-90deg)",
|
||||
transition: "transform 150ms ease",
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("Chat history")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
<Collapse in={listOpen}>
|
||||
<ConversationList activeChatId={activeChatId} onSelect={selectChat} />
|
||||
<Divider my="xs" />
|
||||
</Collapse>
|
||||
|
||||
<Box style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
{waitingForHistory ? (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : (
|
||||
<ChatThread
|
||||
key={threadKey}
|
||||
chatId={activeChatId}
|
||||
initialRows={activeChatId ? messageRows : []}
|
||||
openPage={openPage}
|
||||
onTurnFinished={onTurnFinished}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
402
apps/client/src/features/ai-chat/components/ai-chat-window.tsx
Normal file
402
apps/client/src/features/ai-chat/components/ai-chat-window.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={winRef}
|
||||
className={`${classes.window}${minimized ? ` ${classes.minimized}` : ""}`}
|
||||
style={{
|
||||
left: geom.left,
|
||||
top: geom.top,
|
||||
width: geom.width,
|
||||
// Height omitted when minimized so the `.minimized` CSS auto-height wins.
|
||||
height: minimized ? undefined : geom.height,
|
||||
}}
|
||||
>
|
||||
{/* drag bar / header */}
|
||||
<div className={classes.dragBar} onMouseDown={startDrag}>
|
||||
<IconGripVertical
|
||||
size={14}
|
||||
color="var(--mantine-color-gray-4)"
|
||||
style={{ flex: "none" }}
|
||||
/>
|
||||
<span className={classes.title}>{t("AI chat")}</span>
|
||||
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||
{totalTokens > 0 && (
|
||||
<Tooltip label={t("Tokens used in this chat")} withArrow>
|
||||
<span className={classes.badge}>{formatTokens(totalTokens)}</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.headerBtn}
|
||||
title={t("Minimize")}
|
||||
aria-label={t("Minimize")}
|
||||
onClick={toggleMinimize}
|
||||
>
|
||||
<IconMinus size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.headerBtn}
|
||||
title={t("Close")}
|
||||
aria-label={t("Close")}
|
||||
onClick={() => setWindowOpen(false)}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className={classes.content}>
|
||||
{/* history */}
|
||||
<div className={classes.historySection}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classes.historyHeader}
|
||||
onClick={() => setHistoryOpen((o) => !o)}
|
||||
>
|
||||
<IconChevronDown
|
||||
size={12}
|
||||
style={{
|
||||
transform: historyOpen ? "none" : "rotate(-90deg)",
|
||||
transition: "transform 150ms ease",
|
||||
}}
|
||||
/>
|
||||
<span>{t("Chat history")}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.newChatBtn}
|
||||
title={t("New chat")}
|
||||
aria-label={t("New chat")}
|
||||
onClick={startNewChat}
|
||||
>
|
||||
<IconPlus size={11} />
|
||||
{t("New chat")}
|
||||
</button>
|
||||
</div>
|
||||
{historyOpen && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<ConversationList
|
||||
activeChatId={activeChatId}
|
||||
onSelect={selectChat}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* body: active chat thread */}
|
||||
<div className={classes.body}>
|
||||
{waitingForHistory ? (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : (
|
||||
<ChatThread
|
||||
key={threadKey}
|
||||
chatId={activeChatId}
|
||||
initialRows={activeChatId ? messageRows : []}
|
||||
openPage={openPage}
|
||||
onTurnFinished={onTurnFinished}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* resize affordance icon (drawn manually; native resizer is hidden) */}
|
||||
{!minimized && (
|
||||
<span className={classes.resizeHandle}>
|
||||
<IconArrowsDiagonal size={12} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
<IconSparkles size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
|
||||
Reference in New Issue
Block a user