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)
This commit is contained in:
Eros
2026-08-10 12:41:51 +02:00
committed by Teknium
parent c6ec8a95e6
commit 505f289d37
2 changed files with 121 additions and 1 deletions
+69
View File
@@ -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<string, string> = {};
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(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive />
</MemoryRouter>,
);
}
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
+52 -1
View File
@@ -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 }) {
</span>
</span>
</Button>
{chatPanelCollapsed && (
<Button
ghost
onClick={toggleChatPanel}
title="Show side panel (model + sessions)"
aria-label="Show chat side panel"
className={cn(
"absolute z-10",
"normal-case tracking-normal font-normal",
"rounded border border-current/30",
"bg-black/20",
"opacity-70 hover:opacity-100 hover:border-current/60",
"transition-opacity duration-150",
"top-2 right-2 px-2 py-1 text-xs sm:top-3 sm:right-3",
)}
style={{ color: terminalFg }}
>
<span className="inline-flex items-center gap-1">
<PanelRight className="h-3 w-3 shrink-0" />
<span className="hidden min-[400px]:inline tracking-wide">
panel
</span>
</span>
</Button>
)}
</div>
{!narrow && (
{!narrow && !chatPanelCollapsed && (
<div
id="chat-side-panel"
role="complementary"
aria-label={modelToolsLabel}
className="flex min-h-0 shrink-0 flex-col gap-3 overflow-hidden lg:h-full lg:w-60"
>
<div className="flex h-8 shrink-0 items-center justify-end pr-1">
<Button
ghost
size="icon"
onClick={toggleChatPanel}
aria-label="Collapse chat side panel"
title="Collapse side panel"
className="text-text-secondary hover:text-midground"
>
<X />
</Button>
</div>
{/* Model picker — keeps the rail thin. */}
<div className="shrink-0">
<ChatSidebar