diff --git a/CHANGELOG.md b/CHANGELOG.md index efc5df9..9be66e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Chat history depth setting.** Choose how many previous chat turns are sent with each AI panel message (Settings → AI, default 8). Lower it to save tokens with local models, or set 0 to make every message start fresh. (#111) +- **The AI panel is resizable.** Drag its left edge to make it as wide as you + need, or focus the edge and use the arrow keys. The width is remembered, and + the editor reflows beside it as you drag. (#111) +- **Your chats are kept.** Closing the AI panel or starting a new chat no longer + throws the conversation away. Past chats are listed under the new history + button in the panel header, survive restarting the app, and can be deleted + individually. (#111) +- **The AI icon animation can be turned off.** Settings → AI → "Animate the AI + icon" switches the shimmer on the title-bar AI button off, leaving it as plain + text. (#111) ### Fixed diff --git a/src/App.tsx b/src/App.tsx index 35796e4..25203bc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -75,6 +75,7 @@ import { addRecentFile, getAIConfig, getAIEnabled, + getAIPanelWidth, initAIKey, getLastFile, getOpenInReader, @@ -136,9 +137,10 @@ interface FileData { const IS_MAC = typeof navigator !== "undefined" && /mac/i.test(navigator.platform || navigator.userAgent || ""); const AI_SHORTCUT = IS_MAC ? "⌘J" : "Alt+J"; -// Width of the right-side AI panel; the editor/preview area reserves this much -// padding-right when it's open so content reflows beside it (not under it). -const AI_PANEL_WIDTH = 400; +// The AI panel's width is a persisted user setting, dragged from its left edge +// (#111). App owns it because three things must agree on one number: the panel +// itself, the padding-right the editor/preview reserves so content reflows +// beside it (not under it), and the floating mode toggle that sits clear of it. // Width of the left-side drawers (FileExplorer / TableOfContents); they are // `fixed left-0 w-72` (18rem = 288px), so the editor reserves this much @@ -219,6 +221,8 @@ function AppContent() { const [showFileExplorer, setShowFileExplorer] = useState(false); const [showTOC, setShowTOC] = useState(false); const [showAIPanel, setShowAIPanel] = useState(false); + // Live during a drag; the panel writes the settled value to storage itself. + const [aiPanelWidth, setAiPanelWidth] = useState(getAIPanelWidth); // Proposed document from Agent mode, shown as an inline diff for accept/reject. const [proposedDoc, setProposedDoc] = useState(null); @@ -1950,7 +1954,7 @@ function AppContent() { // editor beside them instead of overlaying it. style={{ paddingLeft: (showFileExplorer || showTOC) ? `${SIDEBAR_WIDTH}px` : 0, - paddingRight: showAIPanel ? `min(${AI_PANEL_WIDTH}px, 90vw)` : 0, + paddingRight: showAIPanel ? `min(${aiPanelWidth}px, 90vw)` : 0, transition: "padding 0.15s ease", }} > @@ -2036,7 +2040,7 @@ function AppContent() { - + {/* Sidebar Panels — only mount when actually open so they don't load their module until first use. */} @@ -2073,6 +2077,8 @@ function AppContent() { selectionText={content.slice(selectionRange.start, selectionRange.end)} aiConfig={aiConfig} onProposeEdit={handleProposeEdit} + width={aiPanelWidth} + onWidthChange={setAiPanelWidth} /> )} diff --git a/src/components/AIPanel.history.test.tsx b/src/components/AIPanel.history.test.tsx new file mode 100644 index 0000000..7f02f79 --- /dev/null +++ b/src/components/AIPanel.history.test.tsx @@ -0,0 +1,201 @@ +// Chat persistence and the history dropdown (#111). The panel unmounts when +// closed, so before this its conversation died with it; these cover the parts +// that make close/reopen and switching chats non-destructive. +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, screen, cleanup, waitFor, fireEvent, act } from "@testing-library/react"; +import { AIPanel } from "./AIPanel"; +import { getChatSessions, setChatSessions } from "../utils/persistence"; +import type { ChatSession } from "../utils/chatSessions"; + +// Nothing here streams; the panel only renders stored transcripts. +vi.mock("../utils/aiChat", async (orig) => ({ + ...(await orig()), + streamChat: vi.fn(), +})); + +const cfg = { endpoint: "https://x/v1/chat/completions", model: "m", apiKey: "k" }; + +const baseProps = { + isOpen: true, + onClose: () => {}, + note: "the document", + fileName: "note.md", + selectionText: "", + aiConfig: cfg, + width: 400, + onWidthChange: () => {}, +}; + +/** + * A stored chat. `text` is the user's question AND the title, because titles are + * derived from the first user message: the panel re-commits the chat it has open + * on mount, which recomputes that title. A fixture whose title disagreed with + * its messages would be silently rewritten and the test would chase a ghost. + */ +const session = (id: string, updatedAt: number, text: string): ChatSession => ({ + id, + title: text, + updatedAt, + messages: [ + { role: "user", content: text }, + { role: "assistant", content: `answer about ${text}` }, + ], +}); + +/** A click that also fires the mousedown the outside-click watcher listens for. */ +const click = (el: HTMLElement) => { + act(() => { + fireEvent.mouseDown(el); + fireEvent.click(el); + }); +}; + +const openHistory = () => click(screen.getByLabelText("Chat history")); + +beforeEach(() => localStorage.clear()); +afterEach(cleanup); + +describe("AIPanel chat history", () => { + it("resumes the most recent chat on mount", () => { + setChatSessions([session("a", 1, "older question"), session("b", 9, "newest question")]); + + render(); + + // The newest session's transcript, not an empty panel. + expect(screen.getByText("newest question")).toBeInTheDocument(); + expect(screen.queryByText("older question")).toBeNull(); + }); + + it("survives the panel closing and reopening", () => { + setChatSessions([session("a", 5, "remember me")]); + + const first = render(); + expect(screen.getByText("remember me")).toBeInTheDocument(); + + // Closing the panel unmounts it entirely — that was the bug. + first.unmount(); + render(); + + expect(screen.getByText("remember me")).toBeInTheDocument(); + }); + + it("shows an empty panel when nothing was ever stored", () => { + render(); + expect(screen.getByText("Ask about this note")).toBeInTheDocument(); + // Nothing to browse, so the history button is not offered. + expect(screen.getByLabelText("Chat history")).toBeDisabled(); + }); + + it("lists stored chats in the dropdown, newest first", () => { + setChatSessions([session("a", 1, "first topic"), session("b", 2, "second topic")]); + + render(); + openHistory(); + + expect(screen.getAllByRole("menuitem").map((el) => el.textContent)).toEqual([ + "second topic", + "first topic", + ]); + }); + + it("switches to a chat picked from history", () => { + setChatSessions([session("a", 1, "the old question"), session("b", 2, "the new question")]); + + render(); + expect(screen.getByText("the new question")).toBeInTheDocument(); + + openHistory(); + click(screen.getByRole("menuitem", { name: "the old question" })); + + expect(screen.getByText("the old question")).toBeInTheDocument(); + expect(screen.queryByText("the new question")).toBeNull(); + }); + + it("starting a new chat keeps the previous one in history", () => { + setChatSessions([session("a", 5, "prior question")]); + + render(); + click(screen.getByLabelText("New chat")); + + // Fresh, empty chat... + expect(screen.getByText("Ask about this note")).toBeInTheDocument(); + expect(screen.queryByText("prior question")).toBeNull(); + + // ...and the old one is still reachable and still stored. + openHistory(); + expect(screen.getByRole("menuitem", { name: "prior question" })).toBeInTheDocument(); + expect(getChatSessions().some((s) => s.id === "a")).toBe(true); + }); + + it("deletes a chat from history and from storage", async () => { + setChatSessions([session("a", 1, "keep me"), session("b", 2, "delete me")]); + + render(); + openHistory(); + click(screen.getByLabelText("Delete chat: keep me")); + + await waitFor(() => expect(getChatSessions().map((s) => s.id)).toEqual(["b"])); + expect(screen.queryByRole("menuitem", { name: "keep me" })).toBeNull(); + }); + + it("deleting the open chat leaves an empty one, not a stale transcript", () => { + setChatSessions([session("b", 9, "showing now")]); + + render(); + expect(screen.getByText("showing now")).toBeInTheDocument(); + + openHistory(); + click(screen.getByLabelText("Delete chat: showing now")); + + expect(screen.queryByText("showing now")).toBeNull(); + expect(screen.getByText("Ask about this note")).toBeInTheDocument(); + }); + + it("closes the dropdown on Escape", () => { + setChatSessions([session("a", 1, "some chat")]); + + render(); + openHistory(); + expect(screen.getByRole("menu")).toBeInTheDocument(); + + act(() => { fireEvent.keyDown(document, { key: "Escape" }); }); + expect(screen.queryByRole("menu")).toBeNull(); + }); + + it("closes the dropdown on a click outside it", () => { + setChatSessions([session("a", 1, "some chat")]); + + render(); + openHistory(); + expect(screen.getByRole("menu")).toBeInTheDocument(); + + act(() => { fireEvent.mouseDown(document.body); }); + expect(screen.queryByRole("menu")).toBeNull(); + }); + + it("ignores a corrupted stored value instead of failing to render", () => { + localStorage.setItem("paperling:aiChatSessions", "{not json"); + render(); + expect(screen.getByText("Ask about this note")).toBeInTheDocument(); + }); + + it("does not store an empty chat just because the panel was opened", () => { + render(); + expect(getChatSessions()).toEqual([]); + }); +}); + +describe("AIPanel width", () => { + it("applies the width it is given", () => { + const { container } = render(); + expect((container.querySelector("aside") as HTMLElement).style.width).toBe("640px"); + }); + + it("exposes a resize separator carrying the current width", () => { + render(); + expect(screen.getByRole("separator", { name: "Resize AI panel" })).toHaveAttribute( + "aria-valuenow", + "512" + ); + }); +}); diff --git a/src/components/AIPanel.tsx b/src/components/AIPanel.tsx index b0a55cb..083c919 100644 --- a/src/components/AIPanel.tsx +++ b/src/components/AIPanel.tsx @@ -3,7 +3,22 @@ import Markdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { streamChat, buildAskMessages, buildAgentMessages, parseEdits, type ChatMessage } from "../utils/aiChat"; import type { AIConfig } from "../utils/aiAssist"; -import { getAIHistoryTurns } from "../utils/persistence"; +import { + getAIHistoryTurns, + getChatSessions, + setChatSessions, + setAIPanelWidth, + AI_PANEL_WIDTH_MIN, + AI_PANEL_WIDTH_MAX, +} from "../utils/persistence"; +import { + deriveTitle, + makeSessionId, + upsertSession, + removeSession, + type ChatSession, +} from "../utils/chatSessions"; +import { PanelResizeHandle } from "./PanelResizeHandle"; import mascotWizard from "../assets/mascot/mascot-wizard.png"; interface AIPanelProps { @@ -17,6 +32,10 @@ interface AIPanelProps { aiConfig: AIConfig; /** Called (Agent mode) with the proposed document to review in the editor. */ onProposeEdit?: (proposedDoc: string) => void; + /** Live panel width in px; owned by App so the editor can reserve the space. */ + width: number; + /** Fires continuously while the edge is dragged. */ + onWidthChange: (px: number) => void; } interface UIMessage { @@ -28,9 +47,21 @@ interface UIMessage { // (Settings → AI, default 8), read live per send — the document itself is // attached only to the latest turn inside buildAskMessages. -export function AIPanel({ isOpen, onClose, note, fileName, selectionText, aiConfig, onProposeEdit }: AIPanelProps) { - const [messages, setMessages] = useState([]); +export function AIPanel({ isOpen, onClose, note, fileName, selectionText, aiConfig, onProposeEdit, width, onWidthChange }: AIPanelProps) { + // Stored chats (#111). Closing the panel unmounts it, so message state used + // to die with it. Read the saved history once, then resume the most recent + // chat, which makes close/reopen and app restarts non-destructive. + const storedRef = useRef(null); + if (storedRef.current === null) storedRef.current = getChatSessions(); + const [sessions, setSessions] = useState(() => storedRef.current ?? []); + const [sessionId, setSessionId] = useState(() => storedRef.current?.[0]?.id ?? makeSessionId()); + const [messages, setMessages] = useState(() => storedRef.current?.[0]?.messages ?? []); + const [historyOpen, setHistoryOpen] = useState(false); const [input, setInput] = useState(""); + // Read by callbacks that must see the newest list without being rebuilt on + // every change (and without a self-triggering effect dependency). + const sessionsRef = useRef(sessions); + sessionsRef.current = sessions; // Ref twin of `input` so the open-effect can restore the draft without // re-running on every keystroke. const inputDraftRef = useRef(""); @@ -143,7 +174,85 @@ export function AIPanel({ isOpen, onClose, note, fileName, selectionText, aiConf }, [input, busy, configured, messages, note, selectionText, aiConfig, mode, onProposeEdit]); const stop = useCallback(() => abortRef.current?.abort(), []); - const clear = useCallback(() => { abortRef.current?.abort(); setMessages([]); setError(null); }, []); + + /** Write one chat into the stored history. Empty chats are not stored. */ + const commitSession = useCallback((id: string, msgs: UIMessage[]) => { + if (!msgs.length) return; + const next = upsertSession(sessionsRef.current, { + id, + title: deriveTitle(msgs), + updatedAt: Date.now(), + messages: msgs, + }); + sessionsRef.current = next; + setSessions(next); + setChatSessions(next); + }, []); + + // Save the active chat once it settles. Gated on `busy` so a streaming reply + // doesn't write to localStorage on every token; the transition back to idle + // (success, error, or abort) is what persists the final transcript. + useEffect(() => { + if (busy) return; + commitSession(sessionId, messages); + }, [busy, messages, sessionId, commitSession]); + + // Start a fresh chat, keeping the current one in history. This is the button + // that used to be "clear", which discarded the conversation outright (#111). + const newChat = useCallback(() => { + abortRef.current?.abort(); + commitSession(sessionId, messages); + setSessionId(makeSessionId()); + setMessages([]); + setError(null); + setHistoryOpen(false); + }, [commitSession, sessionId, messages]); + + const selectSession = useCallback((id: string) => { + setHistoryOpen(false); + if (id === sessionId) return; + abortRef.current?.abort(); + // Save where we are before moving, so switching away mid-conversation + // (or mid-stream) doesn't lose it. + commitSession(sessionId, messages); + const target = sessionsRef.current.find((s) => s.id === id); + if (!target) return; + setSessionId(id); + setMessages(target.messages); + setError(null); + }, [commitSession, sessionId, messages]); + + const deleteSession = useCallback((id: string) => { + const next = removeSession(sessionsRef.current, id); + sessionsRef.current = next; + setSessions(next); + setChatSessions(next); + // Deleting the open chat leaves an empty one rather than a stale view. + if (id === sessionId) { + abortRef.current?.abort(); + setSessionId(makeSessionId()); + setMessages([]); + setError(null); + } + }, [sessionId]); + + // Close the history dropdown on Escape or a click elsewhere. + const historyRef = useRef(null); + useEffect(() => { + if (!historyOpen) return; + const onDown = (e: MouseEvent) => { + if (!historyRef.current?.contains(e.target as Node)) setHistoryOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { e.stopPropagation(); setHistoryOpen(false); } + }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey, true); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey, true); + }; + }, [historyOpen]); if (!isOpen) return null; @@ -158,16 +267,74 @@ export function AIPanel({ isOpen, onClose, note, fileName, selectionText, aiConf