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 54843450..b3707cb9 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 @@ -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(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(""); 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 index 1cb2087f..1e4b0ed8 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx @@ -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 diff --git a/apps/client/src/features/ai-chat/components/chat-input.tsx b/apps/client/src/features/ai-chat/components/chat-input.tsx index 7e5033b0..73a7f126 100644 --- a/apps/client/src/features/ai-chat/components/chat-input.tsx +++ b/apps/client/src/features/ai-chat/components/chat-input.tsx @@ -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(); 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 e2f02110..c39430d1 100644 --- a/apps/client/src/features/page-history/components/history-item.tsx +++ b/apps/client/src/features/page-history/components/history-item.tsx @@ -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 = (