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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user