From 505f289d37dc1ce42d64a22a8e40d85c0154020d Mon Sep 17 00:00:00 2001 From: Eros <94871490+Eros-ITA@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:41:51 +0200 Subject: [PATCH] feat(web): add collapse toggle for the chat side panel The right-hand chat panel (model picker + session list) is a fixed 240px column on desktop with no way to hide it. Add a collapse button (X) in the panel header and a floating 'panel' button over the terminal to reopen it, mirroring the collapsible app sidebar. The choice is persisted in localStorage (hermes-chat-panel-collapsed) so it survives reloads. (cherry picked from commit 854f1325a5490bcc968723a82746c939118f6b67) --- web/src/pages/ChatPage.test.tsx | 69 +++++++++++++++++++++++++++++++++ web/src/pages/ChatPage.tsx | 53 ++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/web/src/pages/ChatPage.test.tsx b/web/src/pages/ChatPage.test.tsx index 907147048a..c078aee434 100644 --- a/web/src/pages/ChatPage.test.tsx +++ b/web/src/pages/ChatPage.test.tsx @@ -158,6 +158,25 @@ type CloseEventLike = { let container: HTMLDivElement; let root: Root; +// jsdom runs without an origin here (per-file @vitest-environment jsdom on a +// node-default config), so localStorage is undefined. Stub it so components +// that persist UI state (side panel collapse) can be exercised. +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + store[key] = String(value); + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + }; +})(); + async function render(ui: ReactNode) { container = document.createElement("div"); document.body.append(container); @@ -220,6 +239,8 @@ beforeEach(() => { }, }); sessionStorage.clear(); + vi.stubGlobal("localStorage", localStorageMock); + localStorageMock.clear(); }); afterEach(async () => { @@ -250,6 +271,54 @@ describe("ChatPage", () => { }); }); +describe("ChatPage side panel collapse", () => { + async function renderChat() { + const { default: ChatPage } = await import("./ChatPage"); + await render( + + + , + ); + } + + it("collapses the desktop side panel and persists the choice", async () => { + localStorage.clear(); + await renderChat(); + await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); + + const collapseButton = container.querySelector( + '[aria-label="Collapse chat side panel"]', + ); + expect(collapseButton).not.toBeNull(); + + await act(async () => { + collapseButton!.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + + expect(localStorage.getItem("hermes-chat-panel-collapsed")).toBe("1"); + expect( + container.querySelector('[aria-label="Collapse chat side panel"]'), + ).toBeNull(); + expect( + container.querySelector('[aria-label="Show chat side panel"]'), + ).not.toBeNull(); + + // Reopening restores the panel and clears the persisted flag. + await act(async () => { + container + .querySelector('[aria-label="Show chat side panel"]')! + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(localStorage.getItem("hermes-chat-panel-collapsed")).toBe("0"); + expect( + container.querySelector('[aria-label="Collapse chat side panel"]'), + ).not.toBeNull(); + }); +}); + // The gated-mode ticket request runs before any socket exists, so a rejection // or a hang emits no `close` event and never arms PTY_CONNECTING_TIMEOUT_MS // (that timer is set after `new WebSocket`). Without its own deadline the tab diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index cfda855f39..46ae92bb41 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -290,6 +290,19 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // tabs because the dep wouldn't change on tab switch. const [mobilePanelOpenRaw, setMobilePanelOpenRaw] = useState(false); const mobilePanelOpen = isActive && mobilePanelOpenRaw; + + // Collapse toggle for the desktop chat side panel (model + sessions), + // persisted in localStorage so the choice survives reloads. + const [chatPanelCollapsed, setChatPanelCollapsed] = useState( + () => localStorage.getItem("hermes-chat-panel-collapsed") === "1", + ); + const toggleChatPanel = useCallback(() => { + setChatPanelCollapsed((prev) => { + const next = !prev; + localStorage.setItem("hermes-chat-panel-collapsed", next ? "1" : "0"); + return next; + }); + }, []); const { setEnd, setTitle } = usePageHeader(); const [sessionTitleState, setSessionTitleState] = useState<{ scope: string; @@ -1717,15 +1730,53 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { + + {chatPanelCollapsed && ( + + )} - {!narrow && ( + {!narrow && !chatPanelCollapsed && (