From 375e0bb478e2018d64fe4cf45a9c73d724c9f1eb Mon Sep 17 00:00:00 2001 From: Razee4315 Date: Thu, 30 Jul 2026 04:44:42 +0500 Subject: [PATCH] feat(ai): resizable panel, kept chats, optional icon animation (#111) The last three asks from Blaze-Leo''s thread. Resizable panel. The width was hardcoded at 400px in three places that all had to agree: the panel, the padding-right the editor reserves so content reflows beside it, and the floating mode toggle that sits clear of it. App now owns one number and hands it to all three. New PanelResizeHandle drags the left edge; SplitDivider could not be reused because it resizes two in-flow panes and therefore works in ratios, while this sizes a fixed right-anchored element and works in pixels from the right edge. Arrow keys nudge it, and the value is clamped 280-900 on both read and write so a stale or hand-edited entry cannot squeeze the document to nothing. onResize fires per pointermove for layout; onCommit fires once on release, which is where the localStorage write goes. Kept chats. Closing the panel unmounts it, so the conversation used to die with it, and "new chat" discarded it outright. Chats are now stored sessions: the panel resumes the most recent one on mount, past chats are listed under a history button, and each can be deleted. Titles derive from the first user message. Growth is capped on two axes, count and total characters, evicting least-recently-updated first, because localStorage is small, shared and has no quota guarantee; the newest chat is kept even if it alone exceeds the budget, since discarding the conversation in progress is worse than briefly exceeding a self-imposed limit. Reads go through sanitizeSessions so a corrupted entry degrades to "no history" instead of breaking the panel. Writes are gated on `busy`, so a streaming reply does not hit storage on every token. Optional icon animation. Settings, AI, "Animate the AI icon" turns the title-bar shimmer off and leaves the glyph as plain text, which is what was asked for. On by default, so nothing changes unless you touch it. TitleBar holds it locally and refreshes from the settings event rather than having it threaded down from App, since nothing in between needs to know. Verified: 56 new tests, 415 total, build clean. - chatSessions.test.ts (25) covers title derivation, both eviction axes, the keep-the-newest rule, and sanitizing corrupt input - AIPanel.history.test.tsx (14) covers resume-on-mount, surviving an actual unmount/remount, switching, deleting the open chat, and not storing an empty chat just because the panel was opened - PanelResizeHandle.test.tsx (12) covers clamping at both bounds, commit exactly once on release, commit on a cancelled pointer, and cursor cleanup when unmounted mid-drag - TitleBar.aiIcon.test.tsx (5) covers both states and the live toggle --- CHANGELOG.md | 10 + src/App.tsx | 16 +- src/components/AIPanel.history.test.tsx | 201 +++++++++++++++++++ src/components/AIPanel.tsx | 179 ++++++++++++++++- src/components/ModeToggle.tsx | 6 +- src/components/PanelResizeHandle.test.tsx | 148 ++++++++++++++ src/components/PanelResizeHandle.tsx | 106 ++++++++++ src/components/SettingsModal.tsx | 8 +- src/components/TitleBar.aiIcon.test.tsx | 77 ++++++++ src/components/TitleBar.tsx | 15 +- src/utils/chatSessions.test.ts | 231 ++++++++++++++++++++++ src/utils/chatSessions.ts | 132 +++++++++++++ src/utils/persistence.ts | 36 ++++ 13 files changed, 1149 insertions(+), 16 deletions(-) create mode 100644 src/components/AIPanel.history.test.tsx create mode 100644 src/components/PanelResizeHandle.test.tsx create mode 100644 src/components/PanelResizeHandle.tsx create mode 100644 src/components/TitleBar.aiIcon.test.tsx create mode 100644 src/utils/chatSessions.test.ts create mode 100644 src/utils/chatSessions.ts 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