Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 11 additions & 5 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {
addRecentFile,
getAIConfig,
getAIEnabled,
getAIPanelWidth,
initAIKey,
getLastFile,
getOpenInReader,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string | null>(null);

Expand Down Expand Up @@ -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",
}}
>
Expand Down Expand Up @@ -2036,7 +2040,7 @@ function AppContent() {
</div>
</div>

<ModeToggle mode={mode} onSetMode={setMode} aiPanelOpen={showAIPanel} />
<ModeToggle mode={mode} onSetMode={setMode} aiPanelOpen={showAIPanel} aiPanelWidth={aiPanelWidth} />

{/* Sidebar Panels — only mount when actually open so they don't
load their module until first use. */}
Expand Down Expand Up @@ -2073,6 +2077,8 @@ function AppContent() {
selectionText={content.slice(selectionRange.start, selectionRange.end)}
aiConfig={aiConfig}
onProposeEdit={handleProposeEdit}
width={aiPanelWidth}
onWidthChange={setAiPanelWidth}
/>
</Suspense>
)}
Expand Down
201 changes: 201 additions & 0 deletions src/components/AIPanel.history.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("../utils/aiChat")>()),
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(<AIPanel {...baseProps} />);

// 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(<AIPanel {...baseProps} />);
expect(screen.getByText("remember me")).toBeInTheDocument();

// Closing the panel unmounts it entirely — that was the bug.
first.unmount();
render(<AIPanel {...baseProps} />);

expect(screen.getByText("remember me")).toBeInTheDocument();
});

it("shows an empty panel when nothing was ever stored", () => {
render(<AIPanel {...baseProps} />);
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(<AIPanel {...baseProps} />);
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(<AIPanel {...baseProps} />);
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(<AIPanel {...baseProps} />);
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(<AIPanel {...baseProps} />);
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(<AIPanel {...baseProps} />);
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(<AIPanel {...baseProps} />);
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(<AIPanel {...baseProps} />);
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(<AIPanel {...baseProps} />);
expect(screen.getByText("Ask about this note")).toBeInTheDocument();
});

it("does not store an empty chat just because the panel was opened", () => {
render(<AIPanel {...baseProps} />);
expect(getChatSessions()).toEqual([]);
});
});

describe("AIPanel width", () => {
it("applies the width it is given", () => {
const { container } = render(<AIPanel {...baseProps} width={640} />);
expect((container.querySelector("aside") as HTMLElement).style.width).toBe("640px");
});

it("exposes a resize separator carrying the current width", () => {
render(<AIPanel {...baseProps} width={512} />);
expect(screen.getByRole("separator", { name: "Resize AI panel" })).toHaveAttribute(
"aria-valuenow",
"512"
);
});
});
Loading
Loading