fix(ai-chat): keep composer draft across new-chat id adoption remount

Typing into the composer while the agent was streaming lost the draft once
the turn finished: on a brand-new chat, adopting the freshly created chat id
changes ChatThread's key and remounts it, wiping ChatInput's local state.

Lift the composer draft into a module-level jotai atom (aiChatDraftAtom) so it
survives the remount. Reset it only on deliberate chat switches — startNewChat,
selectChat, and the page-history "AI agent" badge deep-link — so a draft never
leaks between conversations, while adoption (which goes through a useEffect)
preserves it.
This commit is contained in:
vvzvlad
2026-06-17 23:44:20 +03:00
parent 0cbc9a589f
commit 4379163c21
4 changed files with 30 additions and 6 deletions

View File

@@ -12,3 +12,10 @@ 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);
// The AI chat composer draft (text typed but not yet sent). Held here — OUTSIDE
// ChatThread — so it survives the thread remount that happens when a brand-new
// chat adopts its freshly created id after the first turn finishes. If it lived
// in ChatInput's local state, that remount would wipe text the user typed while
// the agent was still streaming. Reset on deliberate chat switches.
export const aiChatDraftAtom = atom<string>("");

View File

@@ -15,13 +15,14 @@ import {
IconPlus,
IconX,
} from "@tabler/icons-react";
import { useAtom } from "jotai";
import { useAtom, useSetAtom } from "jotai";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib";
@@ -90,6 +91,7 @@ export default function AiChatWindow() {
const queryClient = useQueryClient();
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
// History section starts collapsed (matches the former panel's behavior).
const [historyOpen, setHistoryOpen] = useState(false);
@@ -131,14 +133,16 @@ export default function AiChatWindow() {
const startNewChat = useCallback((): void => {
setActiveChatId(null);
setHistoryOpen(false);
}, [setActiveChatId]);
setDraft("");
}, [setActiveChatId, setDraft]);
const selectChat = useCallback(
(chatId: string): void => {
setActiveChatId(chatId);
setHistoryOpen(false);
setDraft("");
},
[setActiveChatId],
[setActiveChatId, setDraft],
);
// After a turn finishes, refresh the chat list. For a brand-new chat (no id

View File

@@ -1,7 +1,9 @@
import { useState, KeyboardEvent } from "react";
import { KeyboardEvent } from "react";
import { ActionIcon, Group, Textarea, Tooltip } from "@mantine/core";
import { IconPlayerStopFilled, IconSend } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { aiChatDraftAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
interface ChatInputProps {
onSend: (text: string) => void;
@@ -22,7 +24,7 @@ export default function ChatInput({
disabled,
}: ChatInputProps) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const [value, setValue] = useAtom(aiChatDraftAtom);
const send = (): void => {
const text = value.trim();

View File

@@ -11,6 +11,7 @@ import { useSetAtom } from "jotai";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
@@ -43,6 +44,7 @@ function AiAgentBadge({
const { t } = useTranslation();
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
const tooltip = t("Edited by AI agent on behalf of {{name}}", {
@@ -54,10 +56,19 @@ function AiAgentBadge({
event.stopPropagation();
if (!aiChatId) return;
setActiveChatId(aiChatId);
// Switching to another chat must start with a clean composer — clear any
// unsent draft so it does not leak from the previously open chat.
setDraft("");
setAiChatWindowOpen(true);
setHistoryModalOpen(false);
},
[aiChatId, setActiveChatId, setAiChatWindowOpen, setHistoryModalOpen],
[
aiChatId,
setActiveChatId,
setDraft,
setAiChatWindowOpen,
setHistoryModalOpen,
],
);
const badge = (