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:
vvzvlad
2026-06-17 17:11:01 +03:00
parent afd2248a75
commit 7609538f9c
10 changed files with 592 additions and 202 deletions

View File

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

View File

@@ -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 && (

View File

@@ -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 />
</>
);
}

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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 = (

View File

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