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:
@@ -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>("");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user